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")}>
-
- {t("dashboard.quickAddMeasurement")}
-
+ {canAddMeasurement && (
+ onQuickEntry("measurement")}>
+
+ {t("dashboard.quickAddMeasurement")}
+
+ )}
{canAddMood && (
onQuickEntry("mood")}>
@@ -167,12 +175,14 @@ export function DashboardHeader({
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
- onQuickEntry("medicationIntake")}
- >
-
- {t("dashboard.quickAddMedicationIntake")}
-
+ {canAddIntake && (
+ onQuickEntry("medicationIntake")}
+ >
+
+ {t("dashboard.quickAddMedicationIntake")}
+
+ )}
)}
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 {