fix(vaccinations): ask the grant before offering to add, edit or delete a dose

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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 10:36:52 +02:00
1 parent e645921852
commit 7c7e621fd9
2 files changed
+169 -20

No files matched your search

@@ -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, <VaccinationsView />, 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"]),
<VaccinationsView />,
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"]),
<VaccinationsView />,
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"]),
<VaccinationsView />,
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"]),
<VaccinationsView />,
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, <VaccinationsView />, seedEmpty)).toContain(
'data-slot="vaccination-add-empty"',
);
expect(
render(grant("read", ["profile"]), <VaccinationsView />, seedEmpty),
).not.toContain('data-slot="vaccination-add-empty"');
});
});
@@ -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 ? (
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
) : null;
return (
<div className="space-y-6">
<PageHeader
@@ -59,16 +80,7 @@ export function VaccinationsView() {
</span>
}
description={t("vaccinations.subtitle")}
actions={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
actions={addButton}
/>
<VaccinationSheet
@@ -102,21 +114,26 @@ export function VaccinationsView() {
onRetry={() => void refetch()}
/>
) : records.length > 0 ? (
<VaccinationList records={records} onEdit={openEdit} />
<VaccinationList
records={records}
onEdit={canManageProfile ? openEdit : undefined}
/>
) : (
<EmptyState
icon={<Syringe className="size-6" />}
title={t("vaccinations.empty.title")}
description={t("vaccinations.empty.description")}
action={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
canAddDose ? (
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
) : undefined
}
/>
)}