diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index e433653cd..8ba7a480a 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -120,6 +120,15 @@ function grant( }; } +/** The write answer per section, for the pure helpers that take only that. */ +function writeCaps( + level: AccountAccessEntry["level"], + sections: ShareDomain[] | null, +) { + const writable = new Set(delegatedDomains(level, sections, "write")); + return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) }; +} + const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD }; vi.mock("@/hooks/use-auth", () => ({ @@ -486,17 +495,13 @@ describe("linking a document to an illness episode", () => { describe("the capture picker's kinds", () => { const ALL = ["measurement", "medication", "mood"] as const; - const OWNER_CAPS = { canAdd: true, canManageDomain: () => true }; - const WRITER_CAPS = { canAdd: true, canManageDomain: () => false }; - const READER_CAPS = { canAdd: false, canManageDomain: () => false }; + /** The picker asks one question per kind, of that kind's own section. */ + const caps = writeCaps; + const OWNER_CAPS = { canWriteDomain: () => true }; + const WRITER_CAPS = caps("write", null); + const READER_CAPS = caps("read", null); // A guardian: every section with a delegated route answers at MANAGE. - const GUARDIAN_CAPS = { - canAdd: true, - canManageDomain: (domain: string) => - delegatedDomains("manage", null, "manage").includes( - domain as "measurements", - ), - }; + const GUARDIAN_CAPS = caps("manage", null); it("offers everything in the caller's own record", () => { expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([ @@ -526,6 +531,36 @@ describe("the capture picker's kinds", () => { it("offers a read-only delegate nothing", () => { expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]); }); + + it("offers a scoped WRITE delegate only the kinds its sections cover", () => { + // The gap this closes. `canAdd` is the grant's LEVEL with no scope term, + // so a labs-scoped WRITE grant answered true and the picker offered a + // weight form and a dose the server refuses at `grantCoversDomain`. + expect(visibleCaptureKinds(caps("write", ["labs"]), [...ALL])).toEqual([]); + expect( + visibleCaptureKinds(caps("write", ["measurements"]), [...ALL]), + ).toEqual(["measurement"]); + expect( + visibleCaptureKinds(caps("write", ["measurements", "medications"]), [ + ...ALL, + ]), + ).toEqual(["measurement", "medication"]); + }); + + it("offers a documents-scoped WRITE delegate nothing at all", () => { + // The worst case: the vault takes no delegated write at any level, so the + // grant's writable list is empty while `canWrite` still answers true. + expect(visibleCaptureKinds(caps("write", ["documents"]), [...ALL])).toEqual( + [], + ); + }); + + it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => { + expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([ + "mood", + ]); + expect(visibleCaptureKinds(caps("manage", ["labs"]), [...ALL])).toEqual([]); + }); }); /* -------------------------------------------------------------------------- */ @@ -613,7 +648,7 @@ describe("a form opened before the record answered", () => { }); it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => { - const DELEGATE = { canAdd: true, canManageDomain: () => false }; + const DELEGATE = writeCaps("write", null); expect(admittedQuickEntry("mood", DELEGATE)).toBe(null); expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement"); expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe( @@ -622,22 +657,25 @@ describe("a form opened before the record answered", () => { }); it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => { - const GUARDIAN = { - canAdd: true, - canManageDomain: (domain: string) => domain === "mind", - }; - expect(admittedQuickEntry("mood", GUARDIAN)).toBe("mood"); + expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood"); + }); + + it("withdraws a sheet whose section the grant's scope leaves out", () => { + const LABS_ONLY = writeCaps("write", ["labs"]); + for (const sheet of ["measurement", "mood", "medicationIntake"] as const) { + expect(admittedQuickEntry(sheet, LABS_ONLY), sheet).toBe(null); + } }); it("withdraws every quick-entry sheet from a read-only delegate", () => { - const READER = { canAdd: false, canManageDomain: () => false }; + const READER = writeCaps("read", null); for (const sheet of ["measurement", "mood", "medicationIntake"] as const) { expect(admittedQuickEntry(sheet, READER), sheet).toBe(null); } }); it("leaves the owner's own sheets alone", () => { - const OWNER_CAPS = { canAdd: true, canManageDomain: () => true }; + const OWNER_CAPS = { canWriteDomain: () => true }; for (const sheet of ["measurement", "mood", "medicationIntake"] as const) { expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet); } diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index b4dd74aa2..1e0b554cb 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -36,13 +36,19 @@ export function DashboardHeader({ const { t } = useTranslations(); const { user } = useAuth(); const mounted = useMounted(); - // v1.36.x — the dashboard quick-add offers three kinds; a delegation admits - // two of them (a reading, a dose). Mood stays with the account whose record - // it is, and the customize shortcut points at a settings page sharing does - // not cover at all. - const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities(); + // The quick-add offers three kinds, and each one is asked about its own + // section: a reading under `measurements`, a dose under `medications`, a + // mood entry under `mind`. The coarse `canAdd` this used to gate the whole + // menu on carries the grant's LEVEL and no scope, so it answered true for a + // grant that opens neither of the two sections the menu never asked about. + // The customize shortcut points at a settings page sharing does not cover at + // all. + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddMeasurement = canWriteDomain("measurements"); + const canAddIntake = canWriteDomain("medications"); // A mood entry is created at MANAGE under the mind section. - const canAddMood = canManageDomain("mind"); + const canAddMood = canWriteDomain("mind"); + const canAddAnything = canAddMeasurement || canAddIntake || canAddMood; // The pre-hero greeting derivation, kept hydration-safe: `user` comes // from the auth query, which can resolve before this boundary @@ -117,7 +123,7 @@ export function DashboardHeader({ )} - {canAdd && ( + {canAddAnything && ( {/* v1.4.33 maintainer-item-7 — restore proportional sizing @@ -152,10 +158,12 @@ export function DashboardHeader({ phase-A3 fix #1 hardened this with a unit guard at `src/app/__tests__/quick-add-labels.test.ts` — both labels must differ from each other AND from `common.add`. */} - onQuickEntry("measurement")}> - + {canAddMeasurement && ( + onQuickEntry("measurement")}> + + )} {canAddMood && ( onQuickEntry("mood")}> )} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx index 4b39eaef5..189958163 100644 --- a/src/components/dashboard/quick-entry-sheets.tsx +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -34,17 +34,7 @@ import type { ShareDomain } from "@/lib/sharing/scope"; export type QuickEntryDialog = "measurement" | "mood" | "medicationIntake" | null; -/** - * v1.36.x — which of the three the delegation admits, and the same rule the - * capture picker states at length. - * - * A WRITE grant covers entering a reading and marking a dose. A mood entry is - * not an admitted verb and the server refuses it under a switch. - */ -const DELEGABLE_QUICK_ENTRIES: ReadonlySet> = - new Set(["measurement", "medicationIntake"]); - -/** v1.38.12 — the section each sheet writes to; see the capture picker. */ +/** The section each sheet writes to; see the capture picker. */ const QUICK_ENTRY_DOMAIN: Readonly< Record, ShareDomain> > = { @@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly< */ export function admittedQuickEntry( open: QuickEntryDialog, - caps: Pick, + caps: Pick, ): QuickEntryDialog { if (open === null) return open; - if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open; - return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null; + return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null; } /** diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index c92c0a859..8e52a9318 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -51,24 +51,17 @@ import { type CaptureKind = "measurement" | "medication" | "mood"; /** - * v1.36.x — which of the capture surfaces a delegation admits. + * The section each capture surface writes to. * - * A WRITE grant covers entering a reading and marking a dose. It does not - * cover a mood entry: that verb stays on the owner's own authentication and - * the server refuses it under a switch. The picker is the fastest path into - * all three on a phone, so it is the surface where offering an unadmitted one - * costs the most. - */ -const DELEGABLE_CAPTURE_KINDS: ReadonlySet = new Set([ - "measurement", - "medication", -]); - -/** - * v1.38.12 — the section each capture surface writes to. A MANAGE grant that - * reaches the section's routes is offered the surface; a mood entry is a - * MANAGE create under `mind`, so a guardian gets it and a WRITE delegate does - * not. + * One question per kind, asked of that kind's own section. The picker used to + * ask a coarse one as well — a closed list of kinds a WRITE grant admits, + * offered whenever `canAdd` was true — and `canAdd` is bound to the grant's + * LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` + * published `canAdd: true` and got a weight form the server refuses; one + * scoped to `["documents"]`, which takes no delegated write at any level, got + * both. `writableDomains` is already the level × scope × route-table + * intersection, so asking it per section answers both cases and the mood + * entry's MANAGE-only create as well. */ const CAPTURE_KIND_DOMAIN: Readonly> = { measurement: "measurements", @@ -117,13 +110,10 @@ export function admittedCaptureKind( * cannot tap the button that opens it. */ export function visibleCaptureKinds( - caps: Pick, + caps: Pick, kinds: ReadonlyArray, ): CaptureKind[] { - return kinds.filter((kind) => { - if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true; - return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind); - }); + return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind])); } interface CapturePickerProps {