From 7c7e621fd94e81236d5856aa31b036fc5df77a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:36:52 +0200 Subject: [PATCH] fix(vaccinations): ask the grant before offering to add, edit or delete a dose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The immunization log carried no capability check at all: not one reference to the record hook anywhere under `src/components/vaccinations`. The page is presentable to every `profile`-scoped grant and to every whole-record one, so a READ delegate was shown the add button, a clickable row that opens the edit sheet, and the delete inside it — three controls the server refuses with a 403 each. A WRITE delegate was shown two of them. `POST /api/vaccinations` answers at WRITE; the edit, the delete and the restore behind it are `("manage", "profile")`. The two add buttons now ask `canWriteDomain("profile")` and the row's edit affordance asks `canManageDomain("profile")`, which is the split the address book next door already gets right. The list already renders a non-interactive card when it is handed no edit callback, and the sheet is only reachable through one of the two gated entries, so nothing needs a second check that could drift from the first. `delegated-write-affordances.test.tsx` gained a scoped-grant fixture — every fixture in it was `sections: null`, which is why nothing noticed — and six legs over the log: the owner, a READ, a WRITE and a MANAGE delegate holding `profile`, a MANAGE delegate holding another section, and the empty state's own add. Four of them fail against the previous view. --- .../delegated-write-affordances.test.tsx | 134 +++++++++++++++++- .../vaccinations/vaccinations-view.tsx | 55 ++++--- 2 files changed, 169 insertions(+), 20 deletions(-) diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index 4ef817114..c802640c5 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -31,8 +31,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import type { AccountAccess } from "@/lib/sharing/account-access-view"; +import type { + AccountAccess, + AccountAccessEntry, +} from "@/lib/sharing/account-access-view"; import { delegatedDomains } from "@/lib/sharing/domain-write-support"; +import type { ShareDomain } from "@/lib/sharing/scope"; const OWNER = { accountId: "acct-owner", @@ -87,6 +91,35 @@ const MANAGING: AccountAccess = { canSwitch: true, }; +/** + * A grant with a SCOPE, published the way the server publishes one. + * + * Every fixture above carries `sections: null`, and that is the hole the + * section-blind controls lived in: a WRITE grant scoped to one section answers + * `canWrite: true` like any other, so a control asking the coarse question was + * offered in sections the grant never opened. The two lists are the level × + * scope × route-table intersection, derived here by the same function the + * server derives them with rather than by hand. + */ +function grant( + level: AccountAccessEntry["level"], + sections: ShareDomain[] | null, +): AccountAccess { + return { + accounts: [OWNER], + active: { + ...OWNER, + access: level === "read" ? "read" : "write", + level, + sections, + canWrite: level !== "read", + writableDomains: delegatedDomains(level, sections, "write"), + manageableDomains: delegatedDomains(level, sections, "manage"), + }, + canSwitch: true, + }; +} + const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD }; vi.mock("@/hooks/use-auth", () => ({ @@ -140,6 +173,7 @@ import { TodayHero } from "@/components/daily/today-hero"; import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; import { LedgerRowItem } from "@/components/medications/dose-history-ledger"; +import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; import { queryKeys } from "@/lib/query-keys"; import type { DailyDigest } from "@/lib/daily/digest"; import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; @@ -608,3 +642,101 @@ describe("a form opened before the record answered", () => { expect(admittedQuickEntry(null, OWNER_CAPS)).toBe(null); }); }); + +/* -------------------------------------------------------------------------- */ +/* The immunization log */ +/* -------------------------------------------------------------------------- */ + +describe("the immunization log's add, edit and delete", () => { + const DOSE = { + id: "dose-1", + occurredAt: "2026-03-01T00:00:00.000Z", + antigenSlug: null, + vaccineName: "Tetanus", + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + series: [], + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }; + + const seedList = (client: QueryClient) => { + client.setQueryData(queryKeys.vaccinationList(null), { + vaccinations: [DOSE], + }); + }; + + it("offers all three in the caller's own record", () => { + const html = render(OWN_RECORD, , seedList); + expect(html).toContain('data-slot="vaccination-add"'); + // The row opens the edit sheet, which is where the delete lives. + expect(html).toContain('role="button"'); + }); + + it("offers a read-only delegate none of them", () => { + // `POST /api/vaccinations` is WRITE and everything else is MANAGE, so a + // READ delegate was being shown three controls and refused by all three. + const html = render( + grant("read", ["profile"]), + , + seedList, + ); + expect(html).not.toContain('data-slot="vaccination-add"'); + expect(html).not.toContain('role="button"'); + }); + + it("offers a WRITE delegate the add and not the row", () => { + const html = render( + grant("write", ["profile"]), + , + seedList, + ); + expect(html).toContain('data-slot="vaccination-add"'); + expect(html, "editing and deleting a dose are MANAGE").not.toContain( + 'role="button"', + ); + }); + + it("offers a MANAGE delegate holding the section both", () => { + const html = render( + grant("manage", ["profile"]), + , + seedList, + ); + expect(html).toContain('data-slot="vaccination-add"'); + expect(html).toContain('role="button"'); + }); + + it("offers a MANAGE delegate outside the section neither", () => { + // The page is presentable to a `profile` grant; a grant scoped elsewhere + // that still lands on the URL gets a log it can read and nothing else. + const html = render( + grant("manage", ["labs"]), + , + seedList, + ); + expect(html).not.toContain('data-slot="vaccination-add"'); + expect(html).not.toContain('role="button"'); + }); + + it("offers the empty state's add on the same rule", () => { + const seedEmpty = (client: QueryClient) => { + client.setQueryData(queryKeys.vaccinationList(null), { + vaccinations: [], + }); + }; + expect(render(OWN_RECORD, , seedEmpty)).toContain( + 'data-slot="vaccination-add-empty"', + ); + expect( + render(grant("read", ["profile"]), , seedEmpty), + ).not.toContain('data-slot="vaccination-add-empty"'); + }); +}); diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 4bc8e5882..98ac9376d 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -18,6 +18,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { VaccinationList } from "./vaccination-list"; @@ -27,6 +28,15 @@ import { useVaccinations, type Vaccination } from "./use-vaccinations"; export function VaccinationsView() { const { t } = useTranslations(); + const { canWriteDomain, canManageDomain } = useRecordCapabilities(); + // `POST /api/vaccinations` answers at WRITE; the edit, the delete and the + // restore behind it are all MANAGE. The page is presentable to every + // `profile`-scoped grant, so without this a READ delegate was shown add, + // edit and delete, and every one of them was refused by a server that was + // right to refuse. Same split, same domain, same shape as the address book + // next door. + const canAddDose = canWriteDomain("profile"); + const canManageProfile = canManageDomain("profile"); const { data, isLoading, isError, refetch } = useVaccinations(); const records = data?.vaccinations ?? []; @@ -50,6 +60,17 @@ export function VaccinationsView() { setSheetOpen(true); }; + const addButton = canAddDose ? ( + + ) : null; + return (
} description={t("vaccinations.subtitle")} - actions={ - - } + actions={addButton} /> void refetch()} /> ) : records.length > 0 ? ( - + ) : ( } title={t("vaccinations.empty.title")} description={t("vaccinations.empty.description")} action={ - + canAddDose ? ( + + ) : undefined } /> )}