mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(capture): ask each capture surface's own section before offering it
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.
This commit is contained in:
1 parent
3e176db5b7
commit
927496236a
4 files changed
+98
-71
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canAdd && (
|
||||
{canAddAnything && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* 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`. */}
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
{canAddMeasurement && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAddMood && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
@@ -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). */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
{canAddIntake && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -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<NonNullable<QuickEntryDialog>> =
|
||||
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<NonNullable<QuickEntryDialog>, ShareDomain>
|
||||
> = {
|
||||
@@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly<
|
||||
*/
|
||||
export function admittedQuickEntry(
|
||||
open: QuickEntryDialog,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain">,
|
||||
): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<CaptureKind> = 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<Record<CaptureKind, ShareDomain>> = {
|
||||
measurement: "measurements",
|
||||
@@ -117,13 +110,10 @@ export function admittedCaptureKind(
|
||||
* cannot tap the button that opens it.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
caps: Pick<RecordCapabilities, "canWriteDomain">,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
): 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 {
|
||||
|
||||
Reference in new issue
Block a user