From 3e7127dca5baae76525a9c94123d13fb680295a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 12:11:18 +0200 Subject: [PATCH] Ask what the record on screen allows before rendering a control Account sharing shipped read-only, and everything that consulted the grant lived in the application chrome: the nav bars, the banner, the switcher. No feature surface asked. A delegate opened somebody else's record, saw the add button on every page, filled the form, and was refused by a server that was right to refuse. Nothing leaked; it taught people the product was broken. This is the answer every mutation affordance asks for. Two booleans, because a grant at WRITE admits a short closed list of verbs and nothing else: `canAdd` for an entry of an admitted kind, `canManage` for changing what is already there or adding what the list does not name. `canWrite` arrives resolved from the server and is bound, never recomputed; what this decides is only which class of control a resolved level covers. The absent case is the permissive one. Failing closed would blank every add button in the app for every account on every cold load, to spare a delegate one frame between the reload and the account query landing. --- .../__tests__/use-record-capabilities.test.ts | 85 ++++++++++++ src/hooks/use-record-capabilities.ts | 124 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/hooks/__tests__/use-record-capabilities.test.ts create mode 100644 src/hooks/use-record-capabilities.ts diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts new file mode 100644 index 000000000..c7ef43c52 --- /dev/null +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -0,0 +1,85 @@ +/** + * What a delegation lets somebody do, pinned as a table. + * + * The hook itself is one line over `useAuth`; the decision lives in + * `resolveRecordCapabilities`, which is pure and has exactly three inputs a + * session can be in. So the contract is enumerated here rather than inferred + * from a rendered page — and every affordance in the sweep binds one of these + * two booleans, which is what makes the sweep provable at all. + * + * Mutation checks, run: + * - return `canManage: true` for a WRITE record → "a delegate may not change + * what is already there" goes red. + * - return `canAdd: true` for a READ record → "a read-only delegate adds + * nothing" goes red. + * - make the `!active` branch return `canAdd: false` → "the caller's own + * record keeps everything" goes red. + */ +import { describe, expect, it } from "vitest"; + +import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities"; +import type { AccountAccessEntry } from "@/lib/sharing/account-access-view"; + +const READ_ONLY: AccountAccessEntry = { + accountId: "acct-owner", + username: "owner", + displayName: "Margarethe", + access: "read", + canWrite: false, +}; + +const WRITABLE: AccountAccessEntry = { + ...READ_ONLY, + access: "write", + canWrite: true, +}; + +describe("resolveRecordCapabilities", () => { + it("the caller's own record keeps everything", () => { + // `null` is not "no access", it is "this is mine". Both an unswitched + // session and a payload from a server that has never heard of sharing + // land here, which is why the absent case must be the permissive one. + expect(resolveRecordCapabilities(null)).toEqual({ + inSharedRecord: false, + canWrite: false, + canAdd: true, + canManage: true, + }); + expect(resolveRecordCapabilities(undefined)).toEqual( + resolveRecordCapabilities(null), + ); + }); + + it("a read-only delegate adds nothing", () => { + expect(resolveRecordCapabilities(READ_ONLY)).toEqual({ + inSharedRecord: true, + canWrite: false, + canAdd: false, + canManage: false, + }); + }); + + it("a delegate may not change what is already there", () => { + // The asymmetry the whole design rests on: a WRITE grant adds, and that + // is all it does. Not even to an entry the delegate made a minute ago. + expect(resolveRecordCapabilities(WRITABLE)).toEqual({ + inSharedRecord: true, + canWrite: true, + canAdd: true, + canManage: false, + }); + }); + + it("binds the server's boolean rather than the grant's label", () => { + // `access` is descriptive; `canWrite` is the resolved decision. A row that + // says "write" while the server resolved false (lapsed, revoked mid-flight, + // a level this build does not honour) must read as read-only, because two + // programs deciding one person's access is how they end up disagreeing. + const disagreeing: AccountAccessEntry = { + ...READ_ONLY, + access: "write", + canWrite: false, + }; + expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false); + }); +}); diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts new file mode 100644 index 000000000..b57186c6d --- /dev/null +++ b/src/hooks/use-record-capabilities.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useAuth } from "@/hooks/use-auth"; +import { + accountLabel, + type AccountAccessEntry, +} from "@/lib/sharing/account-access-view"; + +/** + * What the person at the keyboard may do to the record on screen. + * + * ## Why this exists + * + * v1.36.0 shipped account sharing read-only, and everything that consulted + * the grant lived in the application chrome: the nav bars, the banner, the + * switcher. No feature surface asked. So a delegate opened `/measurements` + * inside somebody else's record, saw the add button, filled the form, and was + * refused by the server. Nothing leaked, because the refusal was right. It + * still taught them the product was broken. This hook is what every mutation + * affordance asks before it renders. + * + * ## The two answers, and why there are two + * + * A grant at WRITE admits a short, closed list of verbs: entering readings, + * lab results, allergies, family history, an illness entry, a biomarker, a + * tracked value, a side effect, a medication, and marking a dose taken. It + * admits nothing else. Editing, deleting, restoring, purging, importing and + * bulk-acting stay with the owner, including on an entry the delegate added + * themselves a minute ago, and so does every create the list does not name + * (a mood entry, a screener, a cycle day). Two booleans carry that: + * + * - `canAdd` — may this person add one of the admitted kinds. + * - `canManage` — may this person change what is already there, or add + * something the delegation does not cover. True only in one's own record. + * + * A surface that offers an admitted create asks `canAdd`. Everything else + * asks `canManage`. Neither is a permission decision made here: `canWrite` + * arrives resolved from the server on `accountAccess.active` and is bound, + * never recomputed (`account-access-view.ts:11-15`). What this file decides is + * only which class of affordance a resolved level covers, and that mapping is + * a property of the release, not of the caller. + * + * ## Absent, not disabled + * + * The consuming rule, stated once here because it belongs to the whole sweep: + * a control this returns `false` for does not render at all. A greyed-out + * button still says "this exists and does not work for you"; a control that is + * simply not there says "this is not part of what you were given", which is + * the truth. The banner already states the mode globally, so no per-control + * chip explains the absence — only an empty state a person would otherwise + * search a button in gets a sentence. + */ +export interface RecordCapabilities { + /** Is this browser acting on somebody else's record right now. */ + inSharedRecord: boolean; + /** The grant's resolved level for the record on screen. */ + canWrite: boolean; + /** May the caller add an entry of a kind the delegation admits. */ + canAdd: boolean; + /** May the caller change what exists, or add what the delegation excludes. */ + canManage: boolean; +} + +/** + * The mapping, as a pure function of the record the session is inside. + * + * Exported so its contract can be pinned without a click: the three states a + * session can be in are enumerable, and the test enumerates them. `null` is + * one's own record, which is every capability. + */ +export function resolveRecordCapabilities( + active: AccountAccessEntry | null | undefined, +): RecordCapabilities { + if (!active) { + return { + inSharedRecord: false, + canWrite: false, + canAdd: true, + canManage: true, + }; + } + return { + inSharedRecord: true, + canWrite: active.canWrite, + canAdd: active.canWrite, + canManage: false, + }; +} + +/** + * The record on screen, and what may be done to it. + * + * Reads the same resolved block the banner and the switcher read, so the + * chrome and the controls beneath it cannot disagree about what a delegate + * was given. + * + * Before `/api/auth/me` settles, `active` is absent and the answer is the + * caller's own record. That is deliberate rather than fail-closed: failing + * closed would blank every add button in the app for every account on every + * cold load, to spare a delegate one frame. The switch flow ends in a hard + * reload, so a delegate's first paint can show an add control for as long as + * the account query takes, and then it goes. A control that appears and + * withdraws is a much smaller lie than one that stays and 403s, and the + * banner lands from the same query in the same frame. + */ +export function useRecordCapabilities(): RecordCapabilities { + const { user } = useAuth(); + return resolveRecordCapabilities(user?.accountAccess?.active); +} + +/** + * The name of the record this browser is inside, or null in one's own. + * + * The delegate's receipt: a mutation that lands under a switch says which + * record it landed in, because "Saved" alone is the one confirmation a person + * acting for somebody else does not need. Kept beside the capabilities because + * it reads the same resolved entry and would otherwise be a fourth place that + * decides what to call somebody. + */ +export function useActiveRecordName(): string | null { + const { user } = useAuth(); + const active = user?.accountAccess?.active ?? null; + return active ? accountLabel(active) : null; +}