mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user