From 927496236ae35ee457069f13e99fa4c69cb020eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 10:45:01 +0200 Subject: [PATCH] fix(capture): ask each capture surface's own section before offering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture FAB rides the bottom bar on every authenticated page, and its fallback arm was section-blind: return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind); `canAdd` is bound straight to the grant's LEVEL — `grantAllows(grant, "write")`, no scope term at all. So a WRITE grant scoped to `["labs"]` published `canAdd: true` with `writableDomains: ["labs"]`, and a delegate standing on `/labs` was offered a weight form and a dose that the server refuses at `grantCoversDomain`. The worst case is a WRITE grant scoped to `["documents"]`: the vault takes no delegated write at any level, the writable list is empty, and both kinds were still offered. `writableDomains` is already the level × scope × route-table intersection, so one question per kind, asked of that kind's own section, answers the scoped case, the vault case and the mood entry's MANAGE-only create together. The closed kind list and the separate manage arm are both dead once that lands and are gone. The dashboard quick-entry sheets and the header menu carried the same shape — unreachable today, because the dashboard opens only for a whole-record grant — and move with it rather than waiting to become reachable. The picker's legs in the affordances suite now build their capabilities from the same table the server publishes, and four scoped cases join them: labs-only offers nothing, measurements-only offers the reading, documents-only offers nothing at all, and a mind-scoped manage offers the mood entry alone. --- .../delegated-write-affordances.test.tsx | 74 ++++++++++++++----- src/components/dashboard/dashboard-header.tsx | 44 ++++++----- .../dashboard/quick-entry-sheets.tsx | 17 +---- src/components/layout/capture-picker.tsx | 34 +++------ 4 files changed, 98 insertions(+), 71 deletions(-) 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 {