mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(sharing): show a guardian the controls the routes accept (#939)
useRecordCapabilities answers per section now. canWriteDomain and canManageDomain read the two lists the account payload publishes; in one's own record they are always true, and under a refused, pending or unproven context always false. canManage becomes the coarse switch, true when any section is manageable, and is what the banner and the navigation read. Every add, edit and delete control names its own section: labs, mind, profile, illness, cycle, medications, measurements, documents. Creates that a WRITE route answers (visits, practitioners) ask canWriteDomain. The generic delete button, the selection bar, the capture picker, the dashboard quick entry and the bottom nav take the section from their callers. Controls whose routes resolve the caller rather than the record (settings shortcuts, chart overlay preferences, custom metrics, the Today rail, OCR, efficacy targets, credential tabs) ask for the caller's own record instead, and the customise-shortcut guard now requires that shape. The vault has no delegated write route, so its controls stay withheld under every grant, and the documents page says so in one sentence where the upload control would be, in all seven locales.
This commit is contained in:
1 parent
b5c392636f
commit
e95612e3d3
64 files changed
+690
-289
No files matched your search
@@ -9548,6 +9548,7 @@
|
||||
"title": "Dokumente",
|
||||
"subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.",
|
||||
"pageUpload": "Hochladen",
|
||||
"ownerOnlyUpload": "Dokumente kann nur die Inhaberin oder der Inhaber der Akte hinzufügen.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Arztbericht",
|
||||
"DISCHARGE_LETTER": "Entlassungsbrief",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documents",
|
||||
"subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.",
|
||||
"pageUpload": "Upload",
|
||||
"ownerOnlyUpload": "Documents can only be added by the record owner.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Doctor report",
|
||||
"DISCHARGE_LETTER": "Discharge letter",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documentos",
|
||||
"subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.",
|
||||
"pageUpload": "Subir",
|
||||
"ownerOnlyUpload": "Solo el titular del historial puede añadir documentos.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Informe médico",
|
||||
"DISCHARGE_LETTER": "Informe de alta",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documents",
|
||||
"subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.",
|
||||
"pageUpload": "Téléverser",
|
||||
"ownerOnlyUpload": "Seul le titulaire du dossier peut ajouter des documents.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Compte rendu médical",
|
||||
"DISCHARGE_LETTER": "Lettre de sortie",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documenti",
|
||||
"subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.",
|
||||
"pageUpload": "Carica",
|
||||
"ownerOnlyUpload": "Solo il titolare della cartella può aggiungere documenti.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Referto medico",
|
||||
"DISCHARGE_LETTER": "Lettera di dimissione",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "문서",
|
||||
"subtitle": "모든 소견서·리포트·영상을 한곳에서 검색해요 — 저장할 때 암호화돼요.",
|
||||
"pageUpload": "업로드",
|
||||
"ownerOnlyUpload": "문서는 기록 소유자만 추가할 수 있습니다.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "진료용 리포트",
|
||||
"DISCHARGE_LETTER": "퇴원 소견서",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Dokumenty",
|
||||
"subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.",
|
||||
"pageUpload": "Prześlij",
|
||||
"ownerOnlyUpload": "Dokumenty może dodawać tylko właściciel kartoteki.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Raport lekarski",
|
||||
"DISCHARGE_LETTER": "Wypis ze szpitala",
|
||||
|
||||
@@ -12,16 +12,24 @@
|
||||
* it, so this states it. It is structural rather than rendered because these
|
||||
* pages early-return a loading gate under SSR — the markup a delegate would
|
||||
* see is not reachable without a browser, and the property is about the source
|
||||
* either way: the link is inside a `canManage` conditional, or it is not.
|
||||
* either way: the link is inside an `inSharedRecord` conditional, or it is
|
||||
* not.
|
||||
*
|
||||
* v1.38.12 — the guard used to name the manage capability, which was true
|
||||
* only in one's own record. It answers per section now and is true for a
|
||||
* guardian, so a wrench behind it would send a guardian into the wall. The
|
||||
* customise pages live under `/settings`, which is the CALLER's and never the
|
||||
* record's, so the link is withheld on `inSharedRecord` — the one answer no
|
||||
* grant level moves.
|
||||
*
|
||||
* ## What decides
|
||||
*
|
||||
* The AST. The `href` is found as a string literal on a JSX attribute and the
|
||||
* ancestor chain above it is walked for a `&&` whose left-hand side names
|
||||
* `canManage`. A comment about gating cannot satisfy that, and a `canManage`
|
||||
* used elsewhere in the same file cannot either — which a "does this file
|
||||
* mention the symbol" matcher would have accepted, and which is exactly what
|
||||
* `/mood` looked like the day it shipped ungated.
|
||||
* `inSharedRecord`. A comment about gating cannot satisfy that, and an
|
||||
* `inSharedRecord` used elsewhere in the same file cannot either — which a
|
||||
* "does this file mention the symbol" matcher would have accepted, and which
|
||||
* is exactly what `/mood` looked like the day it shipped ungated.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
@@ -34,8 +42,8 @@ const SRC = join(process.cwd(), "src");
|
||||
/** The prefix every customise shortcut points at. */
|
||||
const LAYOUT_PREFIX = "/settings/layout/";
|
||||
|
||||
/** The capability a shared-record surface withholds these on. */
|
||||
const GUARD = "canManage";
|
||||
/** The answer a shared-record surface withholds these on: `!inSharedRecord`. */
|
||||
const GUARD = "inSharedRecord";
|
||||
|
||||
/**
|
||||
* The surfaces that carry a layout shortcut and need no gate.
|
||||
@@ -66,16 +74,16 @@ function allSourceFiles(): string[] {
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Does any ancestor of `node` gate it behind `<GUARD> && …`? */
|
||||
function guardedByCanManage(node: ts.Node): boolean {
|
||||
/** Does any ancestor of `node` gate it behind `!<GUARD> && …`? */
|
||||
function guardedByOwnRecord(node: ts.Node): boolean {
|
||||
for (let cur: ts.Node | undefined = node; cur; cur = cur.parent) {
|
||||
const parent: ts.Node | undefined = cur.parent;
|
||||
if (
|
||||
parent &&
|
||||
ts.isBinaryExpression(parent) &&
|
||||
parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
|
||||
// Only the RIGHT side is the guarded branch. `canManage && x` guards `x`;
|
||||
// `x && canManage` is `canManage` being the guarded thing.
|
||||
// Only the RIGHT side is the guarded branch. `!inSharedRecord && x`
|
||||
// guards `x`; `x && !inSharedRecord` is the answer being the guarded thing.
|
||||
parent.right === cur
|
||||
) {
|
||||
const names = new Set<string>();
|
||||
@@ -87,7 +95,7 @@ function guardedByCanManage(node: ts.Node): boolean {
|
||||
if (names.has(GUARD)) return true;
|
||||
}
|
||||
// A conditional whose whenTrue branch holds the link, guarded on the
|
||||
// capability: `canManage ? <Link/> : null`, the shape `vorsorge-section`
|
||||
// answer: `!inSharedRecord ? <Link/> : null`, the shape `vorsorge-section`
|
||||
// uses.
|
||||
if (
|
||||
parent &&
|
||||
@@ -120,7 +128,7 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
|
||||
if (
|
||||
ts.isStringLiteralLike(node) &&
|
||||
node.text.startsWith(LAYOUT_PREFIX) &&
|
||||
!guardedByCanManage(node)
|
||||
!guardedByOwnRecord(node)
|
||||
) {
|
||||
found.push(node.text);
|
||||
}
|
||||
@@ -160,20 +168,20 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
`a customise link outside a \`${GUARD}\` branch sends a delegate to /settings, which a switch closes`,
|
||||
`a customise link outside a \`!${GUARD}\` branch sends a delegate to /settings, which a switch closes`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matcher reads the branch, not the file", () => {
|
||||
// The shape that shipped: the capability is resolved and used elsewhere in
|
||||
// The shape that shipped: the answer is resolved and used elsewhere in
|
||||
// the module while the link itself sits outside any branch. A matcher
|
||||
// asking "does this file name canManage" calls this clean.
|
||||
// asking "does this file name inSharedRecord" calls this clean.
|
||||
const UNGATED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
return (
|
||||
<>
|
||||
{canManage && <Button data-slot="add" />}
|
||||
{!inSharedRecord && <Button data-slot="add" />}
|
||||
<Link href="/settings/layout/mood">Customise</Link>
|
||||
</>
|
||||
);
|
||||
@@ -186,16 +194,16 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
|
||||
const GATED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
return canManage && <Link href="/settings/layout/mood">Customise</Link>;
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
return !inSharedRecord && <Link href="/settings/layout/mood">Customise</Link>;
|
||||
}
|
||||
`;
|
||||
expect(unguardedShortcuts(GATED, "g.tsx")).toEqual([]);
|
||||
|
||||
const TERNARY = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const wrench = canManage ? (
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const wrench = !inSharedRecord ? (
|
||||
<Link href="/settings/layout/vorsorge">Customise</Link>
|
||||
) : null;
|
||||
return wrench;
|
||||
@@ -203,12 +211,12 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
`;
|
||||
expect(unguardedShortcuts(TERNARY, "t.tsx")).toEqual([]);
|
||||
|
||||
// And the inverted shape is not a gate: `href && canManage` puts the
|
||||
// capability in the guarded position, not the link.
|
||||
// And the inverted shape is not a gate: `href && !inSharedRecord` puts
|
||||
// the answer in the guarded position, not the link.
|
||||
const INVERTED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
return <Link href={"/settings/layout/labs"}>{something && canManage}</Link>;
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
return <Link href={"/settings/layout/labs"}>{something && !inSharedRecord}</Link>;
|
||||
}
|
||||
`;
|
||||
expect(unguardedShortcuts(INVERTED, "i.tsx")).toEqual([
|
||||
|
||||
@@ -162,12 +162,14 @@ describe("what the canonical readers make of the same payload", () => {
|
||||
expect(capabilities.recordKind).toBe(fixture.recordKind);
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
// Resolved server-side and rendered, never re-derived: `canAdd` follows
|
||||
// `canWrite`, and MANAGE does not become `canManage` on the client — a
|
||||
// delegate's management reach is the server's answer, one request at a
|
||||
// time.
|
||||
// `canWrite`, and `canManage` follows the published `manageableDomains`
|
||||
// list rather than the level — a delegate's management reach is the
|
||||
// server's answer, section by section.
|
||||
expect(capabilities.canWrite).toBe(fixture.canWrite);
|
||||
expect(capabilities.canAdd).toBe(fixture.canWrite);
|
||||
expect(capabilities.canManage).toBe(false);
|
||||
const manageable = active?.manageableDomains ?? [];
|
||||
expect(capabilities.canManage).toBe(manageable.length > 0);
|
||||
expect(manageable.length > 0).toBe(fixture.level === "manage");
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function LabsPage() {
|
||||
// Scanning a report is not: it files a document and commits through the OCR
|
||||
// route, and neither is delegated. So a WRITE delegate keeps the manual add
|
||||
// and loses the choice menu around it.
|
||||
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -69,7 +69,7 @@ export default function LabsPage() {
|
||||
isLoading,
|
||||
labsEnabled: enabled,
|
||||
mounted,
|
||||
canManage,
|
||||
ownRecord: !inSharedRecord,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function LabsPage() {
|
||||
point, left of the primary Add and linking to the Labs settings
|
||||
page (view, sort order, biomarker CRUD + reorder). Mirrors the
|
||||
medication page header's wrench glyph + slot + 44px tap floor. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -133,7 +133,7 @@ export default function LabsPage() {
|
||||
mode (vision, or local OCR opted-in for text-only providers), so
|
||||
the surface stays simple for everyone else. When scanning is
|
||||
unavailable, Add opens the manual form directly. */}
|
||||
{ocrCapability.data?.available && canManage ? (
|
||||
{ocrCapability.data?.available && !inSharedRecord ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function MedicationsPageClient() {
|
||||
// to tapping each card in turn — so withholding it made a caregiver with
|
||||
// five morning tablets tap five times for nothing. The customize page stays
|
||||
// withheld; sharing does not cover it at all.
|
||||
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
@@ -433,7 +433,7 @@ export default function MedicationsPageClient() {
|
||||
Same glyph, slot (left of the add button) and responsive
|
||||
44-px mobile tap floor as the dashboard and insights
|
||||
headers. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
|
||||
@@ -21,7 +21,9 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function MoodPageClient() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
// A mood entry is created at MANAGE under the mind section.
|
||||
const canAddMood = canManageDomain("mind");
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -89,7 +91,7 @@ export default function MoodPageClient() {
|
||||
the "not part of what was shared" panel: `/settings` is not a
|
||||
shared-record destination and the shell answers before the
|
||||
page does. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -105,13 +107,13 @@ export default function MoodPageClient() {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
{canManage && (
|
||||
{/* A mood entry is a MANAGE create: a WRITE delegate gets no add
|
||||
path here, a guardian or a MANAGE delegate does. */}
|
||||
{canAddMood && (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="mood-add-entry"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
* Mutation checks, run:
|
||||
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
|
||||
* read-only legs for the intake row and the card menu go red.
|
||||
* - `DeleteButton` dropping its `canManage` bail → "no row delete inside
|
||||
* somebody else's record" goes red.
|
||||
* - `DeleteButton` dropping its `canManageDomain` bail → "no row delete inside
|
||||
* somebody else's record" goes red; answering it for every section → the
|
||||
* vault leg of "a guardian's row delete follows the section" goes red.
|
||||
* - `TodayHero` passing `onDismiss` / `onAction` unconditionally → the rail
|
||||
* legs go red, printing the dismiss control and the check-in buttons.
|
||||
* - `VorsorgeDashboardCard` dropping its `canManage` bail → the mark-done
|
||||
* leg goes red.
|
||||
* - `EpisodeDocumentsCard` dropping its `canManage` bail → the link +
|
||||
* upload leg goes red.
|
||||
* - `VorsorgeDashboardCard` dropping its `canManageDomain` bail → the
|
||||
* mark-done leg goes red.
|
||||
* - `EpisodeDocumentsCard` dropping its `canManageDomain` bail → the link +
|
||||
* upload leg goes red, on the MANAGE arm.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -31,6 +32,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
@@ -42,6 +44,8 @@ const OWNER = {
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
const OWN_RECORD: AccountAccess = {
|
||||
@@ -56,7 +60,30 @@ const READ_ONLY: AccountAccess = {
|
||||
};
|
||||
const WRITABLE: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access: "write", level: "write", canWrite: true },
|
||||
active: {
|
||||
...OWNER,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("write", null, "write"),
|
||||
manageableDomains: [],
|
||||
},
|
||||
canSwitch: true,
|
||||
};
|
||||
/**
|
||||
* v1.38.12 — a MANAGE grant, published as the server publishes it: the
|
||||
* sections with a delegated route, and never the vault.
|
||||
*/
|
||||
const MANAGING: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: {
|
||||
...OWNER,
|
||||
access: "write",
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("manage", null, "write"),
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
},
|
||||
canSwitch: true,
|
||||
};
|
||||
|
||||
@@ -141,25 +168,60 @@ function render(
|
||||
|
||||
describe("row delete", () => {
|
||||
const node = (
|
||||
<DeleteButton onConfirm={() => {}} title="Delete?" description="Gone." />
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => {}}
|
||||
title="Delete?"
|
||||
description="Gone."
|
||||
/>
|
||||
);
|
||||
|
||||
it("renders in the caller's own record", () => {
|
||||
expect(render(OWN_RECORD, node)).toContain("<button");
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record, at both levels", () => {
|
||||
it("is absent inside somebody else's record below MANAGE", () => {
|
||||
// Not disabled. A greyed bin still claims the row is the delegate's to
|
||||
// remove, and it is not — at either grant level, including for a row the
|
||||
// delegate entered themselves.
|
||||
expect(render(READ_ONLY, node)).toBe("");
|
||||
expect(render(WRITABLE, node)).toBe("");
|
||||
});
|
||||
|
||||
it("follows the section for a guardian: labs yes, the vault and owner-only rows no", () => {
|
||||
// The v1.37.0 hold-back answered "no" for every section; #939 is what
|
||||
// that cost. The answer is per section now, and the server's.
|
||||
expect(render(MANAGING, node)).toContain("<button");
|
||||
expect(
|
||||
render(
|
||||
MANAGING,
|
||||
<DeleteButton
|
||||
domain="documents"
|
||||
onConfirm={() => {}}
|
||||
title="Delete?"
|
||||
description="Gone."
|
||||
/>,
|
||||
),
|
||||
).toBe("");
|
||||
// `null`: the row's delete route resolves the caller (custom metrics).
|
||||
expect(
|
||||
render(
|
||||
MANAGING,
|
||||
<DeleteButton
|
||||
domain={null}
|
||||
onConfirm={() => {}}
|
||||
title="Delete?"
|
||||
description="Gone."
|
||||
/>,
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk selection bar", () => {
|
||||
const node = (
|
||||
<SelectionActionBar
|
||||
domain="mind"
|
||||
count={3}
|
||||
onClear={() => {}}
|
||||
onConfirmDelete={() => {}}
|
||||
@@ -175,9 +237,15 @@ describe("bulk selection bar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record", () => {
|
||||
it("is absent inside somebody else's record below MANAGE", () => {
|
||||
expect(render(WRITABLE, node)).toBe("");
|
||||
});
|
||||
|
||||
it("renders for a guardian in a section whose bulk route answers", () => {
|
||||
expect(render(MANAGING, node)).toContain(
|
||||
'data-slot="selection-action-bar"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("marking a dose", () => {
|
||||
@@ -273,12 +341,13 @@ describe("the Today rail's mutating affordances", () => {
|
||||
expect(html).toContain("Keep it");
|
||||
});
|
||||
|
||||
it("withholds both inside somebody else's record, at either level", () => {
|
||||
it("withholds both inside somebody else's record, at every level", () => {
|
||||
// Dismissing an observation and answering a coach check-in are neither of
|
||||
// them an admitted create, and both write through routes that resolve the
|
||||
// CALLER — so a delegate tapping either would file it against their own
|
||||
// record if the route allowed it, and gets a 403 because it does not.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
// record if the route allowed it, and gets a 403 because it does not. A
|
||||
// MANAGE grant changes nothing here: no section reaches a caller route.
|
||||
for (const access of [READ_ONLY, WRITABLE, MANAGING]) {
|
||||
const html = render(access, node);
|
||||
expect(html).not.toContain('data-slot="priority-card-dismiss"');
|
||||
expect(html).not.toContain("Keep it");
|
||||
@@ -326,7 +395,7 @@ describe("the Vorsorge summary's mark-done", () => {
|
||||
expect(html).toContain("Done");
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record, at either level", () => {
|
||||
it("is absent inside somebody else's record below MANAGE", () => {
|
||||
// `/checkups` already withheld this exact action; the dashboard summary
|
||||
// offering it was the inconsistency that showed nobody had asked.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
@@ -335,6 +404,12 @@ describe("the Vorsorge summary's mark-done", () => {
|
||||
expect(html).not.toContain("Done");
|
||||
}
|
||||
});
|
||||
|
||||
it("is offered to a guardian: the satisfy route answers at MANAGE under measurements", () => {
|
||||
const html = render(MANAGING, node, seed);
|
||||
expect(html).toContain("Dental check-up");
|
||||
expect(html).toContain("Done");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -350,12 +425,13 @@ describe("linking a document to an illness episode", () => {
|
||||
expect(html).toContain("Upload");
|
||||
});
|
||||
|
||||
it("offers neither inside somebody else's record, at either level", () => {
|
||||
it("offers neither inside somebody else's record, at every level", () => {
|
||||
// Both write through `POST /api/documents/inbound/bulk`, which the vault
|
||||
// gates on the same answer throughout — the episode side of the same link
|
||||
// had no gate at all, and the upload deep-linked to a page whose own
|
||||
// upload control is already withheld.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
// upload control is already withheld. The vault has no delegated write
|
||||
// route, so a MANAGE grant is refused the same way.
|
||||
for (const access of [READ_ONLY, WRITABLE, MANAGING]) {
|
||||
const html = render(access, node);
|
||||
expect(html).not.toContain(">Link<");
|
||||
expect(html).not.toContain("Upload");
|
||||
@@ -374,24 +450,45 @@ 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 };
|
||||
// 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",
|
||||
),
|
||||
};
|
||||
|
||||
it("offers everything in the caller's own record", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: true }, [...ALL]),
|
||||
).toEqual(["measurement", "medication", "mood"]);
|
||||
expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a delegate only what the delegation admits", () => {
|
||||
// A reading and a dose are admitted verbs. A mood entry is not, and the
|
||||
// server refuses it under a switch.
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: false }, [...ALL]),
|
||||
).toEqual(["measurement", "medication"]);
|
||||
// A reading and a dose are admitted verbs. A mood entry is a MANAGE
|
||||
// create, and the server refuses it under a WRITE grant.
|
||||
expect(visibleCaptureKinds(WRITER_CAPS, [...ALL])).toEqual([
|
||||
"measurement",
|
||||
"medication",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a guardian the mood entry too, because its route answers", () => {
|
||||
expect(visibleCaptureKinds(GUARDIAN_CAPS, [...ALL])).toEqual([
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a read-only delegate nothing", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: false, canManage: false }, [...ALL]),
|
||||
).toEqual([]);
|
||||
expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,7 +577,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, canManage: false };
|
||||
const DELEGATE = { canAdd: true, canManageDomain: () => false };
|
||||
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
|
||||
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
|
||||
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
|
||||
@@ -488,15 +585,23 @@ 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");
|
||||
});
|
||||
|
||||
it("withdraws every quick-entry sheet from a read-only delegate", () => {
|
||||
const READER = { canAdd: false, canManage: false };
|
||||
const READER = { canAdd: false, canManageDomain: () => false };
|
||||
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, canManage: true };
|
||||
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ export function ChartOverlayControls({
|
||||
// reasoning that keeps the mood tag layout off the delegable list. So a
|
||||
// delegate reads the owner's chart in its stored shape and is not offered a
|
||||
// dial that would silently fail.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
if (inSharedRecord) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -196,6 +196,7 @@ export function CustomMetricDetail({
|
||||
<>
|
||||
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
|
||||
<DeleteButton
|
||||
domain={null}
|
||||
onConfirm={() => deleteMetric.mutate()}
|
||||
title={t("customMetrics.deleteConfirmTitle")}
|
||||
description={t("customMetrics.deleteConfirmDescription")}
|
||||
|
||||
@@ -261,6 +261,7 @@ export function CustomMetricHistoryList({
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain={null}
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("customMetrics.entry.deleteConfirmTitle")}
|
||||
description={t(
|
||||
|
||||
@@ -38,7 +38,9 @@ export function CustomMetricList() {
|
||||
// Logging a value against a custom metric was admitted for a release and is
|
||||
// not any more: this paragraph named the gap, the release shipped over it,
|
||||
// and the write withdrew rather than the sentence staying true.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
// Every custom-metric write resolves the caller (`requireAuth`); no grant
|
||||
// reaches it at any level.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -57,7 +59,7 @@ export function CustomMetricList() {
|
||||
router.push(`/custom-metrics/${saved.id}`);
|
||||
}
|
||||
|
||||
if (!canManage) return null;
|
||||
if (inSharedRecord) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -12,7 +12,11 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const errorQuery = {
|
||||
|
||||
@@ -93,7 +93,8 @@ export function CycleView() {
|
||||
// v1.36.x — no grant level admits a cycle write. Inside somebody else's
|
||||
// record the page stays readable and every path into the log-day sheet and
|
||||
// the profile settings is absent, rather than opening onto a refusal.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageCycle = canManageDomain("cycle");
|
||||
|
||||
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
|
||||
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
|
||||
@@ -188,7 +189,7 @@ export function CycleView() {
|
||||
// One action. The settings glyph that used to sit beside it was a
|
||||
// second door to the tab directly below, and no other page in the
|
||||
// app carries a gear in that corner.
|
||||
canManage ? (
|
||||
canManageCycle ? (
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -275,7 +276,7 @@ export function CycleView() {
|
||||
) : null}
|
||||
{/* Log CTA — first period when no cycle exists yet, next period
|
||||
when the count paused on an overdue cycle. */}
|
||||
{canManage &&
|
||||
{canManageCycle &&
|
||||
!loading &&
|
||||
!calendarError &&
|
||||
verdict?.dayOfCycle == null ? (
|
||||
@@ -333,7 +334,7 @@ export function CycleView() {
|
||||
>
|
||||
{t("cycle.tabs.insights")}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
{canManageCycle && (
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
@@ -385,7 +386,7 @@ export function CycleView() {
|
||||
null)
|
||||
: null
|
||||
}
|
||||
onSelectDay={canManage ? openSheet : undefined}
|
||||
onSelectDay={canManageCycle ? openSheet : undefined}
|
||||
onVisibleMonthChange={setVisibleMonth}
|
||||
/>
|
||||
</>
|
||||
@@ -453,7 +454,7 @@ export function CycleView() {
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data && canManage ? (
|
||||
) : profileQuery.data && canManageCycle ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
|
||||
@@ -114,12 +114,13 @@ export function TodayHero({
|
||||
const dismissItem = usePriorityItemDismiss();
|
||||
// The rail's two mutating affordances — dismissing an observation and
|
||||
// answering a coach check-in — are neither of them an admitted create, so
|
||||
// they ask `canManage` and are absent at both grant levels. Both write
|
||||
// they ask for the caller's own record and are absent under every grant. Both write
|
||||
// through routes that resolve the CALLER (`POST /api/daily/digest/dismiss`,
|
||||
// `PATCH /api/coach/plans/[id]`), which under a switch means a 403 rather
|
||||
// than a stray row; the control still goes, because an affordance the
|
||||
// server is on record refusing should not be on the page.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const ownRecord = !inSharedRecord;
|
||||
|
||||
// The coach check-in card's keep / let-go intents carry the plan id after the
|
||||
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
|
||||
@@ -237,9 +238,9 @@ export function TodayHero({
|
||||
<PriorityCard
|
||||
key={`${item.kind}-${i}`}
|
||||
item={item}
|
||||
onAction={canManage ? handleAction : undefined}
|
||||
onAction={ownRecord ? handleAction : undefined}
|
||||
onDismiss={
|
||||
canManage ? (itemKey) => dismissItem.mutate(itemKey) : undefined
|
||||
ownRecord ? (itemKey) => dismissItem.mutate(itemKey) : undefined
|
||||
}
|
||||
actionsPending={
|
||||
item.kind === "coach_checkin" &&
|
||||
|
||||
@@ -40,7 +40,9 @@ export function DashboardHeader({
|
||||
// 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, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
// A mood entry is created at MANAGE under the mind section.
|
||||
const canAddMood = canManageDomain("mind");
|
||||
|
||||
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
|
||||
// from the auth query, which can resolve before this boundary
|
||||
@@ -98,7 +100,7 @@ export function DashboardHeader({
|
||||
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
|
||||
touch-target contract the add button also honours, shrinking
|
||||
back to the 40 px icon footprint on sm+. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -154,7 +156,7 @@ export function DashboardHeader({
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
{canManage && (
|
||||
{canAddMood && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
export type QuickEntryDialog =
|
||||
"measurement" | "mood" | "medicationIntake" | null;
|
||||
@@ -43,6 +44,15 @@ export type QuickEntryDialog =
|
||||
const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
new Set(["measurement", "medicationIntake"]);
|
||||
|
||||
/** v1.38.12 — the section each sheet writes to; see the capture picker. */
|
||||
const QUICK_ENTRY_DOMAIN: Readonly<
|
||||
Record<NonNullable<QuickEntryDialog>, ShareDomain>
|
||||
> = {
|
||||
measurement: "measurements",
|
||||
medicationIntake: "medications",
|
||||
mood: "mind",
|
||||
};
|
||||
|
||||
/**
|
||||
* The sheet that may actually be on screen.
|
||||
*
|
||||
@@ -59,9 +69,10 @@ const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
*/
|
||||
export function admittedQuickEntry(
|
||||
open: QuickEntryDialog,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
): QuickEntryDialog {
|
||||
if (open === null || caps.canManage) return open;
|
||||
if (open === null) return open;
|
||||
if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open;
|
||||
return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
* Shared single-row delete button + confirm dialog for the data-
|
||||
@@ -30,11 +31,14 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
* v1.36.x — this is the one place a row delete is spelled across the app
|
||||
* (measurements, lab results, biomarkers, allergies, family history, custom
|
||||
* metrics, medication inventory), so the delegation rule is enforced here
|
||||
* rather than at twelve call sites: deleting is the owner's alone, at every
|
||||
* grant level, so inside somebody else's record the trigger is absent. Not
|
||||
* disabled — a greyed bin still claims the row is the delegate's to remove.
|
||||
* A caller that ever needs a delete a delegate MAY perform would have to say
|
||||
* so explicitly, and today there is none.
|
||||
* rather than at twelve call sites. Not disabled when withheld — a greyed bin
|
||||
* still claims the row is the delegate's to remove.
|
||||
*
|
||||
* v1.38.12 — deleting is a MANAGE verb, and whether a MANAGE grant reaches it
|
||||
* depends on the section the row belongs to (`domain-write-support.ts`), so
|
||||
* the caller names the section and this component asks `canManageDomain` for
|
||||
* it. `null` says the row's delete route resolves the caller rather than the
|
||||
* record (custom metrics), which no grant reaches at any level.
|
||||
*/
|
||||
export function DeleteButton({
|
||||
onConfirm,
|
||||
@@ -45,7 +49,10 @@ export function DeleteButton({
|
||||
triggerTitle,
|
||||
className = "",
|
||||
iconClassName = "h-3.5 w-3.5",
|
||||
domain,
|
||||
}: {
|
||||
/** The section the delete route answers under, or `null` for owner-only. */
|
||||
domain: ShareDomain | null;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -57,8 +64,9 @@ export function DeleteButton({
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
|
||||
if (!allowed) return null;
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
* Selection action bar for the data-management lists (measurements +
|
||||
@@ -31,10 +32,13 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
* The confirm copy is passed in because the title/body differ per surface
|
||||
* (measurements vs mood) and the count is interpolated by the caller.
|
||||
*
|
||||
* v1.36.x — bulk delete is the owner's alone at every grant level, so the bar
|
||||
* is absent inside somebody else's record. The row checkboxes that feed it are
|
||||
* dropped at their lists for the same reason; this guard is the backstop, so a
|
||||
* list that forgets cannot leave a delete button floating over the page.
|
||||
* v1.36.x — bulk delete is a MANAGE verb, so the bar is absent inside somebody
|
||||
* else's record below that level. The row checkboxes that feed it are dropped
|
||||
* at their lists for the same reason; this guard is the backstop, so a list
|
||||
* that forgets cannot leave a delete button floating over the page.
|
||||
*
|
||||
* v1.38.12 — the caller names the section its bulk route answers under, and
|
||||
* the bar asks `canManageDomain` for it; `null` marks an owner-only route.
|
||||
*/
|
||||
export function SelectionActionBar({
|
||||
count,
|
||||
@@ -43,7 +47,10 @@ export function SelectionActionBar({
|
||||
isDeleting,
|
||||
confirmTitle,
|
||||
confirmBody,
|
||||
domain,
|
||||
}: {
|
||||
/** The section the bulk-delete route answers under, or `null` for owner-only. */
|
||||
domain: ShareDomain | null;
|
||||
count: number;
|
||||
onClear: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
@@ -52,9 +59,10 @@ export function SelectionActionBar({
|
||||
confirmBody: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
|
||||
|
||||
if (count <= 0 || !canManage) return null;
|
||||
if (count <= 0 || !allowed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -373,7 +373,8 @@ export function DocumentDetailSheet({
|
||||
(fact) => fact.factType === "OBSERVATION" && fact.status === "APPROVED",
|
||||
).length ?? 0;
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageDocuments = canManageDomain("documents");
|
||||
const labsModuleEnabled = user?.modules?.labs !== false;
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
@@ -387,14 +388,14 @@ export function DocumentDetailSheet({
|
||||
// document reads behind this sheet are delegable; the AI verbs above them are
|
||||
// not, and both of these routes stay on the caller's own authentication. A
|
||||
// delegate would fire two queries that must 403 in order to compute an
|
||||
// affordance every branch below already withholds on `canManage`.
|
||||
// affordance every branch below already withholds on `canManageDocuments`.
|
||||
const capability = useDocumentAiCapability(
|
||||
canManage && open && documentId !== null,
|
||||
canManageDocuments && open && documentId !== null,
|
||||
);
|
||||
// When auto-read is ON, reading happens automatically on upload — the manual
|
||||
// per-document AI action row is redundant and collapses away.
|
||||
const autoRead = useDocumentsAutoAiRead(
|
||||
canManage && open && documentId !== null,
|
||||
canManageDocuments && open && documentId !== null,
|
||||
);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
@@ -709,7 +710,7 @@ export function DocumentDetailSheet({
|
||||
owner's alone. Inside somebody else's record the sheet reads
|
||||
the document and offers the download, and the leading slot
|
||||
simply has nothing in it. */}
|
||||
{canManage ? (
|
||||
{canManageDocuments ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
@@ -751,7 +752,7 @@ export function DocumentDetailSheet({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && (
|
||||
{canManageDocuments && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShareOpen(true)}
|
||||
@@ -829,7 +830,7 @@ export function DocumentDetailSheet({
|
||||
// The stored summary is read; generating one writes to the
|
||||
// owner's record, so a delegate reads what is there and has no
|
||||
// generate control.
|
||||
canGenerate={canManage}
|
||||
canGenerate={canManageDocuments}
|
||||
summaryState={doc.summaryState}
|
||||
generatedAtLabel={
|
||||
doc.summaryGeneratedAt
|
||||
@@ -842,7 +843,7 @@ export function DocumentDetailSheet({
|
||||
onGenerate={generateStoredSummary}
|
||||
/>
|
||||
|
||||
{canManage && (
|
||||
{canManageDocuments && (
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
autoReadEnabled={autoReadEnabled}
|
||||
@@ -906,7 +907,7 @@ export function DocumentDetailSheet({
|
||||
maxLength={200}
|
||||
placeholder={t("documents.detail.titlePlaceholder")}
|
||||
/>
|
||||
) : !canManage ? (
|
||||
) : !canManageDocuments ? (
|
||||
<p
|
||||
id="document-title-input"
|
||||
className={cn(
|
||||
@@ -952,7 +953,7 @@ export function DocumentDetailSheet({
|
||||
without accepting a change is the honest rendering. */}
|
||||
<Select
|
||||
value={doc.kind}
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
onValueChange={(value) =>
|
||||
patch.mutate({ kind: value as InboundDocumentKindValue })
|
||||
}
|
||||
@@ -975,7 +976,7 @@ export function DocumentDetailSheet({
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-date-field"
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
value={doc.documentDate ?? ""}
|
||||
onChange={(value) =>
|
||||
patch.mutate({
|
||||
@@ -1006,7 +1007,7 @@ export function DocumentDetailSheet({
|
||||
{t("documents.detail.noConditions")}
|
||||
</span>
|
||||
) : null}
|
||||
{canManage && (episodes.data?.length ?? 0) > 0 ? (
|
||||
{canManageDocuments && (episodes.data?.length ?? 0) > 0 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -1058,7 +1059,7 @@ export function DocumentDetailSheet({
|
||||
alreadyLinked={
|
||||
doc.encounterLinks.length > 0 && pickedEncounterId === null
|
||||
}
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
value={pickedEncounterId}
|
||||
onChange={setSuggestedEncounter}
|
||||
/>
|
||||
@@ -1069,7 +1070,7 @@ export function DocumentDetailSheet({
|
||||
<VaccinationDocumentSuggestion
|
||||
anchor={doc.reportDate ?? doc.documentDate}
|
||||
documentId={doc.id}
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1111,7 +1112,7 @@ export function DocumentDetailSheet({
|
||||
values" over its stored text — never a re-upload. */}
|
||||
<DocumentFactsSection
|
||||
doc={doc}
|
||||
canManage={canManage}
|
||||
canManage={canManageDocuments}
|
||||
aiEnabled={aiEnabled}
|
||||
labsModuleEnabled={labsModuleEnabled}
|
||||
/>
|
||||
|
||||
@@ -241,8 +241,10 @@ export function DocumentsView() {
|
||||
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
// the vault reads and nothing more: no upload path, no selection, no bulk
|
||||
// bar, no corpus backfill.
|
||||
const { canManage, inSharedRecord, sections } = useRecordCapabilities();
|
||||
// bar, no corpus backfill. `canManageDomain("documents")` says so from the
|
||||
// server's table, and answers false under every grant today.
|
||||
const { canManageDomain, inSharedRecord, sections } = useRecordCapabilities();
|
||||
const canManageDocuments = canManageDomain("documents");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -424,7 +426,9 @@ export function DocumentsView() {
|
||||
},
|
||||
[episodeIdFilter, t],
|
||||
);
|
||||
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
|
||||
const { dropActive } = usePageFileDrop(
|
||||
canManageDocuments ? enqueueFiles : undefined,
|
||||
);
|
||||
|
||||
const episodes = useIllnessEpisodes(
|
||||
true,
|
||||
@@ -439,7 +443,7 @@ export function DocumentsView() {
|
||||
const contentIndex = usage.data?.contentIndex;
|
||||
const canIndexContent = contentIndex?.enabled ?? false;
|
||||
const showIndexAll =
|
||||
canManage &&
|
||||
canManageDocuments &&
|
||||
canIndexContent &&
|
||||
contentIndex !== undefined &&
|
||||
contentIndex.totalCount > 0 &&
|
||||
@@ -872,7 +876,7 @@ export function DocumentsView() {
|
||||
title={t("documents.title")}
|
||||
description={t("documents.subtitle")}
|
||||
actions={
|
||||
canManage ? (
|
||||
canManageDocuments ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
@@ -884,13 +888,23 @@ export function DocumentsView() {
|
||||
}
|
||||
/>
|
||||
|
||||
{canManage && (
|
||||
{canManageDocuments && (
|
||||
<UploadZone
|
||||
usage={usage.data}
|
||||
inputRef={uploadInputRef}
|
||||
onFiles={enqueueFiles}
|
||||
/>
|
||||
)}
|
||||
{inSharedRecord && !canManageDocuments && (
|
||||
// Where the upload zone would be: the one empty space a person acting
|
||||
// for somebody else would otherwise search a control in.
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="documents-owner-only"
|
||||
>
|
||||
{t("documents.ownerOnlyUpload")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DocumentFilterBar
|
||||
searchValue={searchDraft}
|
||||
@@ -932,7 +946,7 @@ export function DocumentsView() {
|
||||
description={t("documents.empty.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
canManage ? (
|
||||
canManageDocuments ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
@@ -963,9 +977,9 @@ export function DocumentsView() {
|
||||
isFetchingNextPage={list.isFetchingNextPage}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelected={canManage ? toggleSelected : undefined}
|
||||
onToggleSelected={canManageDocuments ? toggleSelected : undefined}
|
||||
onOpen={openDetail}
|
||||
onDelete={canManage ? (id) => deleteBulk([id]) : undefined}
|
||||
onDelete={canManageDocuments ? (id) => deleteBulk([id]) : undefined}
|
||||
highlightId={upload.highlightId}
|
||||
onPrefetch={prefetchDetail}
|
||||
/>
|
||||
@@ -979,7 +993,7 @@ export function DocumentsView() {
|
||||
contentIndexEnabled={usage.data?.contentIndex.enabled}
|
||||
/>
|
||||
|
||||
{canManage && selectedIds.size > 0 ? (
|
||||
{canManageDocuments && selectedIds.size > 0 ? (
|
||||
<DocumentBulkBar
|
||||
selectedCount={selectedIds.size}
|
||||
episodes={(episodes.data ?? []).map((e) => ({
|
||||
|
||||
@@ -49,10 +49,11 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
const { user } = useAuth();
|
||||
// Linking a document to an episode writes through
|
||||
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
|
||||
// `canManage` throughout. The episode side of the same link had no gate at
|
||||
// `canManageDocuments` throughout. The episode side of the same link had no gate at
|
||||
// all, so the vault refused what this card offered. Reading the linked
|
||||
// documents stays: the list rows and "show all" are pure navigation.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageDocuments = canManageDomain("documents");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
@@ -146,7 +147,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{canManage ? (
|
||||
{canManageDocuments ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -158,7 +159,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
</Button>
|
||||
{/* The upload affordance is a deep link into the vault with
|
||||
this episode pre-filtered. The vault's own upload control
|
||||
is `canManage`-gated, so an ungated link here would send a
|
||||
is `canManageDocuments`-gated, so an ungated link here would send a
|
||||
delegate to a page with nothing to press. */}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={vaultHref}>
|
||||
@@ -184,7 +185,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
{canManageDocuments ? (
|
||||
<DocumentLinkPicker
|
||||
episodeId={episodeId}
|
||||
open={pickerOpen}
|
||||
|
||||
@@ -24,7 +24,12 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canWrite: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
|
||||
@@ -30,7 +30,9 @@ import { VisitCard } from "./visit-card";
|
||||
|
||||
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
// `POST /api/encounters` answers at WRITE under the profile section.
|
||||
const canAddVisit = canWriteDomain("profile");
|
||||
const list = useEncounters(enabled);
|
||||
|
||||
// `null` closed, `"new"` create, a row edits it.
|
||||
@@ -45,7 +47,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const past = list.data?.past ?? [];
|
||||
const empty = upcoming.length === 0 && past.length === 0;
|
||||
|
||||
const addButton = canManage ? (
|
||||
const addButton = canAddVisit ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
|
||||
@@ -36,7 +36,9 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
// The pre-linked visit is a `POST /api/encounters`, which answers at WRITE.
|
||||
const canAddVisit = canWriteDomain("profile");
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [session, setSession] = useState(0);
|
||||
|
||||
@@ -114,7 +116,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
{canAddVisit ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -135,7 +137,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
{canAddVisit ? (
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={sheetOpen}
|
||||
|
||||
@@ -94,7 +94,8 @@ export function IllnessDayTimeline({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — logging a day is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageIllness = canManageDomain("illness");
|
||||
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
|
||||
|
||||
const dayLogs = data?.dayLogs ?? [];
|
||||
@@ -119,7 +120,7 @@ export function IllnessDayTimeline({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.empty")}
|
||||
</p>
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -41,7 +41,8 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — logging a day, editing and resolving an episode are the
|
||||
// owner's; a delegate reads the episode.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageIllness = canManageDomain("illness");
|
||||
const fmt = useFormatters();
|
||||
const {
|
||||
data: episode,
|
||||
@@ -98,7 +99,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
canManageIllness ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setLogOpen(true)}
|
||||
|
||||
@@ -74,7 +74,8 @@ function EpisodeCard({
|
||||
view,
|
||||
}: EpisodeCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageIllness = canManageDomain("illness");
|
||||
const fmt = useFormatters();
|
||||
const active = episode.resolvedAt === null;
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
@@ -109,7 +110,7 @@ function EpisodeCard({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -145,7 +146,7 @@ function EpisodeCard({
|
||||
<span className="block truncate">{episode.label}</span>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<CardAction>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
@@ -211,7 +212,7 @@ function EpisodeCard({
|
||||
flow, promoted from a busy inline cluster. The card navigates to
|
||||
the detail surface on its title link; this is the one explicit
|
||||
button. */}
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<div className="mt-auto pt-0">
|
||||
<Button
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
@@ -319,7 +320,7 @@ export function IllnessView() {
|
||||
// v1.36.x — opening an illness entry is an admitted delegated write. Logging
|
||||
// a day against one, editing it, resolving it and deleting it are not, so a
|
||||
// delegate can start the record of an illness and nothing more.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
@@ -391,8 +392,9 @@ export function IllnessView() {
|
||||
actions={
|
||||
<>
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
{canManage && (
|
||||
Illness settings page (view + reorder). `/settings` is the
|
||||
caller's, not the record's, so no grant reaches it. */}
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("shouldProbeOcrCapability", () => {
|
||||
isLoading: false,
|
||||
labsEnabled: true,
|
||||
mounted: true,
|
||||
canManage: true,
|
||||
ownRecord: true,
|
||||
};
|
||||
|
||||
expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
|
||||
@@ -17,7 +17,7 @@ describe("shouldProbeOcrCapability", () => {
|
||||
shouldProbeOcrCapability({ ...ownerControl, isAuthenticated: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...ownerControl, canManage: false }),
|
||||
shouldProbeOcrCapability({ ...ownerControl, ownRecord: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...ownerControl, labsEnabled: false }),
|
||||
|
||||
@@ -151,6 +151,7 @@ export function BiomarkerManager() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(marker.id)}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t("labs.biomarker.deleteConfirmDescription")}
|
||||
|
||||
@@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200;
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageLabs = canManageDomain("labs");
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
full marker editor behind the pencil, so the separate range
|
||||
control was dropped). */}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMarker.mutate()}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t("labs.biomarker.deleteConfirmDescription")}
|
||||
@@ -340,7 +342,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
{canManage && (
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -77,7 +77,8 @@ function parseDecimal(raw: string): number | null {
|
||||
*/
|
||||
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageLabs = canManageDomain("labs");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Client-side column sort over the in-memory reading feed.
|
||||
@@ -379,7 +380,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManage && (
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -393,6 +394,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
|
||||
@@ -47,15 +47,16 @@ export function shouldProbeOcrCapability({
|
||||
isLoading,
|
||||
labsEnabled,
|
||||
mounted,
|
||||
canManage,
|
||||
ownRecord,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
labsEnabled: boolean;
|
||||
mounted: boolean;
|
||||
canManage: boolean;
|
||||
/** In the caller's own record; the probe and the commit resolve the caller, never a grant. */
|
||||
ownRecord: boolean;
|
||||
}): boolean {
|
||||
return isAuthenticated && !isLoading && labsEnabled && mounted && canManage;
|
||||
return isAuthenticated && !isLoading && labsEnabled && mounted && ownRecord;
|
||||
}
|
||||
|
||||
/** Capability probe — refetched when the scan dialog opens. */
|
||||
|
||||
@@ -20,6 +20,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { CapturePicker } from "@/components/layout/capture-picker";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
CapturePicker,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -105,13 +109,12 @@ export function BottomNav() {
|
||||
// v1.36.0 — acting on somebody else's record. Same gate as the sidebar, from
|
||||
// the same resolved payload, so the two bars cannot disagree about what a
|
||||
// delegate is offered.
|
||||
const {
|
||||
inSharedRecord: sharedRecord,
|
||||
sections,
|
||||
canAdd,
|
||||
canManage,
|
||||
} = useRecordCapabilities();
|
||||
const canCapture = canAdd || canManage;
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord: sharedRecord, sections } = capabilities;
|
||||
// The capture button opens the picker, so it shows exactly when the picker
|
||||
// would offer at least one kind — the same per-section rule, asked once.
|
||||
const canCapture =
|
||||
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER).length > 0;
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
@@ -63,8 +64,20 @@ const DELEGABLE_CAPTURE_KINDS: ReadonlySet<CaptureKind> = new Set([
|
||||
"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.
|
||||
*/
|
||||
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
|
||||
measurement: "measurements",
|
||||
medication: "medications",
|
||||
mood: "mind",
|
||||
};
|
||||
|
||||
/** The order the chooser lists them in. */
|
||||
const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
@@ -104,11 +117,11 @@ export function admittedCaptureKind(
|
||||
* cannot tap the button that opens it.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
): CaptureKind[] {
|
||||
return kinds.filter((kind) => {
|
||||
if (caps.canManage) return true;
|
||||
if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true;
|
||||
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
|
||||
});
|
||||
}
|
||||
|
||||
+5
-1
@@ -27,7 +27,11 @@ vi.mock("@/hooks/use-encounters", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -79,7 +79,8 @@ export function VorsorgeDashboardCard() {
|
||||
// (`POST /api/measurement-reminders/[id]/satisfy` resolves the caller), and
|
||||
// the dashboard summary offering what the dedicated page withholds was the
|
||||
// tell that this row had never been asked.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMeasurements = canManageDomain("measurements");
|
||||
const { data: reminders, isError, refetch } = useMeasurementReminders();
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
@@ -197,7 +198,7 @@ export function VorsorgeDashboardCard() {
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
{canManage ? (
|
||||
{canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
|
||||
@@ -223,7 +223,8 @@ export function VorsorgeSection({
|
||||
variant?: "settings" | "page";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canManageMeasurements = canManageDomain("measurements");
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: reminders,
|
||||
@@ -382,7 +383,7 @@ export function VorsorgeSection({
|
||||
// and no grant level admits creating, editing or satisfying one. Inside
|
||||
// somebody else's record the schedule stays readable and every control that
|
||||
// would change it is absent.
|
||||
const addButton = canManage ? (
|
||||
const addButton = canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
@@ -396,8 +397,9 @@ export function VorsorgeSection({
|
||||
// v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view,
|
||||
// reorder, per-reminder enable toggles), left of the Add button — the
|
||||
// canonical medication-page pattern. The old kebab "Anpassen" sheet is
|
||||
// gone (MOD-07); its toggles moved to the settings page.
|
||||
const wrenchButton = canManage ? (
|
||||
// gone (MOD-07); its toggles moved to the settings page. `/settings` is the
|
||||
// caller's, not the record's, so no grant reaches it.
|
||||
const wrenchButton = !inSharedRecord ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -716,7 +718,7 @@ export function VorsorgeSection({
|
||||
title={t("measurementReminders.empty.title")}
|
||||
description={t("measurementReminders.empty.description")}
|
||||
action={
|
||||
canManage ? (
|
||||
canManageMeasurements ? (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
@@ -864,7 +866,8 @@ function VorsorgeCard({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMeasurements = canManageDomain("measurements");
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
|
||||
// The next-due phrase reads off the SAME zone as the last-done date below
|
||||
@@ -938,7 +941,7 @@ function VorsorgeCard({
|
||||
|
||||
// Single med-style kebab: Edit + Delete. The Delete item is the shared
|
||||
// confirm-on-delete control rendered as a full-width menu row.
|
||||
const headerActions = canManage ? (
|
||||
const headerActions = canManageMeasurements ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -1009,7 +1012,7 @@ function VorsorgeCard({
|
||||
// `resolve.ts` states the same boundary server-side: an appointment reminder
|
||||
// is free-text by construction.
|
||||
const fileVisitButton =
|
||||
canManage && !isLinked ? (
|
||||
canManageMeasurements && !isLinked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1028,7 +1031,7 @@ function VorsorgeCard({
|
||||
// on the card. Outline against the filled primary: the same pairing, and
|
||||
// the same rule as everything else in this bar — one constant look, never
|
||||
// tinted by how overdue the checkup is.
|
||||
const postponeButton = canManage ? (
|
||||
const postponeButton = canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1042,7 +1045,7 @@ function VorsorgeCard({
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const primaryButton = canManage ? (
|
||||
const primaryButton = canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 flex-1"
|
||||
@@ -1272,7 +1275,7 @@ function VorsorgeCard({
|
||||
ungated copy, and which branch a person sees is a per-browser
|
||||
preference that survives the switch — so the identical action
|
||||
was offered or withheld depending on a view toggle. */}
|
||||
{canManage ? (
|
||||
{canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1292,7 +1295,7 @@ function VorsorgeCard({
|
||||
{/* Icon-only in the dense list, labelled in the cards branch —
|
||||
the same action either way, never one branch offering what
|
||||
the other withholds. */}
|
||||
{canManage ? (
|
||||
{canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1309,7 +1312,7 @@ function VorsorgeCard({
|
||||
) : null}
|
||||
{/* Free-text reminders only: a typed / screening reminder has no
|
||||
practice visit behind it. */}
|
||||
{canManage && !isLinked ? (
|
||||
{canManageMeasurements && !isLinked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1304,6 +1304,7 @@ export function MeasurementList({
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="measurements"
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(m.id)
|
||||
}
|
||||
@@ -1504,6 +1505,7 @@ export function MeasurementList({
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="measurements"
|
||||
iconClassName="h-4 w-4"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
@@ -1535,6 +1537,7 @@ export function MeasurementList({
|
||||
|
||||
{/* v1.15.13 — page-scoped multi-select action bar. */}
|
||||
<SelectionActionBar
|
||||
domain="measurements"
|
||||
count={selectedOnPage}
|
||||
onClear={clearSelection}
|
||||
onConfirmDelete={onConfirmBulkDelete}
|
||||
|
||||
@@ -393,7 +393,9 @@ function RetargetControl({
|
||||
// owner chose — not an admitted create, and `PUT .../efficacy/target`
|
||||
// resolves the caller, so it is refused at both grant levels. The Wirkung
|
||||
// tab itself stays readable; only the dial goes.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
// `PUT /api/medications/[id]/efficacy/target` resolves the caller, not the
|
||||
// record: no grant reaches it, so the editor belongs to the owner alone.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -443,7 +445,7 @@ function RetargetControl({
|
||||
}
|
||||
};
|
||||
|
||||
if (!canManage || items.length === 0) return null;
|
||||
if (inSharedRecord || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -185,12 +185,23 @@ type TabSlug = (typeof TAB_SLUGS)[number];
|
||||
* none of it at any level.
|
||||
*/
|
||||
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
|
||||
// Schedule times are a MANAGE route, but the tab also carries the reminder
|
||||
// and notification settings, which resolve the caller.
|
||||
"zeitplan",
|
||||
"bestand",
|
||||
// Per-medication tokens: a credential surface, never delegated.
|
||||
"api",
|
||||
// Phase config, export and the token-adjacent rows resolve the caller.
|
||||
"erweitert",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.38.12 — tabs whose every write is a MANAGE route under `medications`,
|
||||
* so a grant that reaches those routes is shown the tab. The supply ledger
|
||||
* is one; the three above are not, because each mixes in a route that
|
||||
* resolves the caller and would 403 under a switch.
|
||||
*/
|
||||
const MANAGE_TABS: ReadonlySet<TabSlug> = new Set(["bestand"]);
|
||||
|
||||
/**
|
||||
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
|
||||
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
|
||||
@@ -242,7 +253,8 @@ export function MedicationDetailTabs({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -308,9 +320,16 @@ export function MedicationDetailTabs({
|
||||
(slug !== "injektion" || isInjectable) &&
|
||||
(slug !== "zeitplan" || !asNeeded) &&
|
||||
(slug !== "wirkung" || hasEfficacyTarget) &&
|
||||
(canManage || !OWNER_ONLY_TABS.has(slug)),
|
||||
(!inSharedRecord || !OWNER_ONLY_TABS.has(slug)) &&
|
||||
(canManageMedications || !MANAGE_TABS.has(slug)),
|
||||
),
|
||||
[isInjectable, asNeeded, hasEfficacyTarget, canManage],
|
||||
[
|
||||
isInjectable,
|
||||
asNeeded,
|
||||
hasEfficacyTarget,
|
||||
inSharedRecord,
|
||||
canManageMedications,
|
||||
],
|
||||
);
|
||||
|
||||
const requestedRaw = searchParams?.get("tab");
|
||||
@@ -908,7 +927,7 @@ export function MedicationDetailTabs({
|
||||
(`PUT /api/medications/[id]` is not delegable), so the deep link is
|
||||
gated exactly like the hero button it stands in for. */}
|
||||
<MedicationWizardDialog
|
||||
open={editOpen && canManage}
|
||||
open={editOpen && canManageMedications}
|
||||
onOpenChange={setEditOpen}
|
||||
mode="edit"
|
||||
initial={payload}
|
||||
|
||||
@@ -598,7 +598,8 @@ export function LedgerRowItem({
|
||||
onUnpin: (eventId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
|
||||
const actionable = isSlotActionable(row);
|
||||
@@ -733,7 +734,7 @@ export function LedgerRowItem({
|
||||
items in one menu, a delegate gets skip as its own control beside
|
||||
take, which is the shape the card already uses. The owner keeps it
|
||||
where it was. */}
|
||||
{showTakenButton && canAdd && !canManage && (
|
||||
{showTakenButton && canAdd && !canManageMedications && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -754,7 +755,7 @@ export function LedgerRowItem({
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
{canManageMedications && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -63,7 +63,8 @@ export function IntakeHistoryEditable({
|
||||
}: IntakeHistoryEditableProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -135,7 +136,7 @@ export function IntakeHistoryEditable({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3" data-slot="intake-history-editable-body">
|
||||
{canManage && selected.size > 0 && (
|
||||
{canManageMedications && selected.size > 0 && (
|
||||
<div
|
||||
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
|
||||
role="region"
|
||||
|
||||
@@ -592,8 +592,9 @@ function IntakeRowActions({
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
}) {
|
||||
// v1.36.x — editing or deleting a recorded dose is the owner's.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
if (!canManageMedications) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -62,9 +62,10 @@ export function MedicationCardMenu({
|
||||
// medication and opening its history editor are not. When nothing in the
|
||||
// menu survives, the trigger goes with it rather than opening onto an empty
|
||||
// sheet. The card itself is untouched: no wash, no badge, no colour change.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const showSideEffect = onLogSideEffect != null && canAdd;
|
||||
if (!canManage && !showSideEffect) return null;
|
||||
if (!canManageMedications && !showSideEffect) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -87,7 +88,7 @@ export function MedicationCardMenu({
|
||||
className="w-56"
|
||||
data-slot="medication-card-menu"
|
||||
>
|
||||
{canManage && (
|
||||
{canManageMedications && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
@@ -101,7 +102,7 @@ export function MedicationCardMenu({
|
||||
)}
|
||||
{showSideEffect && onLogSideEffect && (
|
||||
<>
|
||||
{canManage && <DropdownMenuSeparator />}
|
||||
{canManageMedications && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onLogSideEffect}>
|
||||
<Stethoscope className="mr-2 h-4 w-4" />
|
||||
{t("medications.glp1LogSideEffect")}
|
||||
|
||||
@@ -36,7 +36,8 @@ export function IntakeHistoryPreview({
|
||||
}: IntakeHistoryPreviewProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — importing dose history is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -45,7 +46,7 @@ export function IntakeHistoryPreview({
|
||||
title={t("medications.detail.intake.title")}
|
||||
dataSlot="medication-detail-intake-history-section"
|
||||
headerExtras={
|
||||
canManage ? (
|
||||
canManageMedications ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -53,6 +53,7 @@ import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DeleteButton } from "@/components/data-list/delete-button";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SettingsGroup } from "@/components/medications/settings-group";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -112,6 +113,7 @@ export function InventorySection({
|
||||
deliveryForm?: string;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -301,19 +303,24 @@ export function InventorySection({
|
||||
{/* v1.16.11 — cross-link to the low-stock alert setting: the
|
||||
threshold lives in Settings → Notifications, but the question
|
||||
"when will it warn me?" comes up here, where the stock lives. */}
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/settings/notifications#low-stock"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="inventory-low-stock-link"
|
||||
>
|
||||
{lowStockDays !== null
|
||||
? t("medications.detail.bestand.lowStockLinkOn", {
|
||||
days: lowStockDays,
|
||||
})
|
||||
: t("medications.detail.bestand.lowStockLinkOff")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* `/settings` is not a shared-record destination, so the cross-link
|
||||
is withheld under a switch rather than opening onto the "not part
|
||||
of what was shared" panel. */}
|
||||
{!inSharedRecord && (
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/settings/notifications#low-stock"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="inventory-low-stock-link"
|
||||
>
|
||||
{lowStockDays !== null
|
||||
? t("medications.detail.bestand.lowStockLinkOn", {
|
||||
days: lowStockDays,
|
||||
})
|
||||
: t("medications.detail.bestand.lowStockLinkOff")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{/* v1.16.11 — packaging economics surfaced where the stock lives:
|
||||
@@ -443,6 +450,7 @@ export function InventorySection({
|
||||
{t("medications.detail.bestand.adjustButton")}
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="medications"
|
||||
onConfirm={() => void deleteItem(item)}
|
||||
title={t("medications.detail.bestand.deleteTitle")}
|
||||
description={t("medications.detail.bestand.deleteDescription")}
|
||||
|
||||
@@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
||||
|
||||
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -346,7 +347,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
{canManageMedications && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -75,7 +75,8 @@ export function InstrumentCard({
|
||||
// v1.36.x — a screener is answered by the person it is about. No grant
|
||||
// level admits it, so inside somebody else's record the card reads as
|
||||
// history and carries no way to start one.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMind = canManageDomain("mind");
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
|
||||
const displayTz = useDisplayTimezone();
|
||||
@@ -178,7 +179,7 @@ export function InstrumentCard({
|
||||
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
|
||||
Nothing sits below it now: the attribution moved to the header's
|
||||
info control, so the Start button stands alone with clean space. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<div className="mt-auto pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
|
||||
@@ -37,7 +37,8 @@ export function InstrumentDetail({
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
// v1.36.x — see the card: starting a screener is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMind = canManageDomain("mind");
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
|
||||
const displayTz = useDisplayTimezone();
|
||||
@@ -76,7 +77,7 @@ export function InstrumentDetail({
|
||||
|
||||
{/* The Start action stays reachable here too — the detail is a calm
|
||||
reading surface, not a dead end. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
|
||||
@@ -177,7 +177,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
// v1.36.x — mood is not a delegated verb. Inside somebody else's record the
|
||||
// list stays readable and every control that would change it is absent.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMind = canManageDomain("mind");
|
||||
const queryClient = useQueryClient();
|
||||
// URL-owned filter state (the documents-vault pattern): mood, source and
|
||||
// date range parse from `?mood&source&from&to` and every change writes back
|
||||
@@ -721,7 +722,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
>
|
||||
{t("mood.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst && canManage ? (
|
||||
) : onAddFirst && canManageMind ? (
|
||||
<Button size="sm" onClick={onAddFirst}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.emptyAddFirst")}
|
||||
@@ -747,7 +748,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
<TableRow>
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
@@ -800,7 +801,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
@@ -844,7 +845,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -856,6 +857,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="mind"
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
@@ -898,7 +900,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
stays the single control and owns the 44px hit area
|
||||
via an `after` hit-slop (clicks on a pseudo-element
|
||||
hit-test against its host button). */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<div className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
@@ -945,7 +947,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
and delete actions meet WCAG 2.5.5 on touch
|
||||
devices. The desktop table keeps its denser
|
||||
h-8 w-8 since pointer targets allow it. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -957,6 +959,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="mind"
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
iconClassName="h-4 w-4"
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
@@ -972,6 +975,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
|
||||
{/* v1.15.13 — page-scoped multi-select action bar. */}
|
||||
<SelectionActionBar
|
||||
domain="mind"
|
||||
count={selectedOnPage}
|
||||
onClear={clearSelection}
|
||||
onConfirmDelete={onConfirmBulkDelete}
|
||||
|
||||
@@ -21,7 +21,11 @@ vi.mock("@/hooks/use-practitioners", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-debounced-value", () => ({
|
||||
|
||||
@@ -44,7 +44,10 @@ import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
// `POST /api/practitioners` answers at WRITE; editing and deleting at MANAGE.
|
||||
const canAddPractitioner = canWriteDomain("profile");
|
||||
const canManageProfile = canManageDomain("profile");
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
const list = usePractitioners(debounced, enabled);
|
||||
@@ -87,7 +90,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
}, [rows]);
|
||||
const showGroupHeadings = groups.some(([specialty]) => specialty !== null);
|
||||
|
||||
const addButton = canManage ? (
|
||||
const addButton = canAddPractitioner ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
@@ -186,7 +189,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
{canManageProfile ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -106,6 +106,7 @@ export function AllergyManager() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="profile"
|
||||
onConfirm={() => deleteMutation.mutate(row.id)}
|
||||
title={t("records.allergies.deleteConfirmTitle")}
|
||||
description={t("records.allergies.deleteConfirmDescription")}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function FamilyHistoryManager() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="profile"
|
||||
onConfirm={() => deleteMutation.mutate(row.id)}
|
||||
title={t("records.family.deleteConfirmTitle")}
|
||||
description={t("records.family.deleteConfirmDescription")}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* nothing" goes red.
|
||||
* - make the `!active` branch return `canAdd: false` → "the caller's own
|
||||
* record keeps everything" goes red.
|
||||
* - answer `canManageDomain` from `level === "manage"` instead of the list →
|
||||
* "the vault is never manageable" goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -22,6 +24,8 @@ import {
|
||||
resolveRecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
|
||||
const READ_ONLY: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
@@ -33,13 +37,20 @@ const READ_ONLY: AccountAccessEntry = {
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
// The lists are what the server publishes for each level over the whole
|
||||
// record: the same helper `resolveAccountAccess` runs, so the fixture is the
|
||||
// payload and not a guess at it.
|
||||
const WRITABLE: AccountAccessEntry = {
|
||||
...READ_ONLY,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("write", null, "write"),
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
const MANAGING: AccountAccessEntry = {
|
||||
@@ -47,6 +58,8 @@ const MANAGING: AccountAccessEntry = {
|
||||
access: "write",
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("manage", null, "write"),
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
};
|
||||
|
||||
const SCOPED: AccountAccessEntry = {
|
||||
@@ -59,7 +72,8 @@ describe("resolveRecordCapabilities", () => {
|
||||
// `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({
|
||||
const own = resolveRecordCapabilities(null);
|
||||
expect(own).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
@@ -71,13 +85,21 @@ describe("resolveRecordCapabilities", () => {
|
||||
sections: null,
|
||||
recordKind: "self",
|
||||
});
|
||||
expect(resolveRecordCapabilities(undefined)).toEqual(
|
||||
resolveRecordCapabilities(null),
|
||||
);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(own.canWriteDomain(domain), domain).toBe(true);
|
||||
expect(own.canManageDomain(domain), domain).toBe(true);
|
||||
}
|
||||
expect(resolveRecordCapabilities(undefined)).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
recordKind: "self",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a malformed published block instead of treating it as own record", () => {
|
||||
expect(resolveRecordCapabilities(null, true)).toEqual({
|
||||
const refused = resolveRecordCapabilities(null, true);
|
||||
expect(refused).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
@@ -87,10 +109,15 @@ describe("resolveRecordCapabilities", () => {
|
||||
recordKind: "shared",
|
||||
accessRefused: true,
|
||||
});
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(refused.canWriteDomain(domain), domain).toBe(false);
|
||||
expect(refused.canManageDomain(domain), domain).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("a read-only delegate adds nothing", () => {
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
|
||||
const reader = resolveRecordCapabilities(READ_ONLY);
|
||||
expect(reader).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
@@ -99,12 +126,17 @@ describe("resolveRecordCapabilities", () => {
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(reader.canWriteDomain(domain), domain).toBe(false);
|
||||
expect(reader.canManageDomain(domain), domain).toBe(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({
|
||||
const writer = resolveRecordCapabilities(WRITABLE);
|
||||
expect(writer).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
@@ -113,6 +145,14 @@ describe("resolveRecordCapabilities", () => {
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
// Writable where a WRITE route exists, manageable nowhere.
|
||||
expect(writer.canWriteDomain("measurements")).toBe(true);
|
||||
expect(writer.canWriteDomain("labs")).toBe(true);
|
||||
expect(writer.canWriteDomain("mind")).toBe(false);
|
||||
expect(writer.canWriteDomain("documents")).toBe(false);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(writer.canManageDomain(domain), domain).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the level and the sections through untouched", () => {
|
||||
@@ -128,22 +168,45 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(resolveRecordCapabilities(MANAGING).level).toBe("manage");
|
||||
});
|
||||
|
||||
it("does not hand a manage grant the affordances its routes cannot answer yet", () => {
|
||||
// The deliberate gap, pinned so it cannot close by accident. MANAGE will
|
||||
// bring the edit and delete controls back, and it does so in the release
|
||||
// where the routes behind them answer — not in the one that merely
|
||||
// publishes the level. A `canManage: true` here today would paint
|
||||
// controls that 403, which is the exact failure these booleans exist to
|
||||
// prevent.
|
||||
expect(resolveRecordCapabilities(MANAGING)).toEqual({
|
||||
it("hands a manage grant exactly the sections its routes answer for", () => {
|
||||
// The v1.37.0 hold-back answered `canManage: false` here until the routes
|
||||
// behind the edit and delete controls existed. They exist; the answer is
|
||||
// the server's per-section list, and the coarse boolean is "is that list
|
||||
// non-empty".
|
||||
const manager = resolveRecordCapabilities(MANAGING);
|
||||
expect(manager).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
canManage: false,
|
||||
canManage: true,
|
||||
level: "manage",
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
expect(manager.canManageDomain("mind")).toBe(true);
|
||||
expect(manager.canManageDomain("profile")).toBe(true);
|
||||
expect(manager.canManageDomain("labs")).toBe(true);
|
||||
expect(manager.canWriteDomain("mind")).toBe(true);
|
||||
});
|
||||
|
||||
it("never lets a shared record manage the vault", () => {
|
||||
// The vault has no delegated write route, so the server's list never
|
||||
// names it — and the hook binds the list rather than the level. Answering
|
||||
// from `level === "manage"` would paint the upload control that 403s.
|
||||
expect(
|
||||
resolveRecordCapabilities(MANAGING).canManageDomain("documents"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveRecordCapabilities(MANAGING).canWriteDomain("documents"),
|
||||
).toBe(false);
|
||||
const claimsEverything: AccountAccessEntry = {
|
||||
...MANAGING,
|
||||
manageableDomains: [],
|
||||
};
|
||||
// An empty list from the server is an empty answer, whatever the level.
|
||||
const held = resolveRecordCapabilities(claimsEverything);
|
||||
expect(held.canManage).toBe(false);
|
||||
expect(held.canManageDomain("mind")).toBe(false);
|
||||
});
|
||||
|
||||
it("binds the server's boolean rather than the grant's label", () => {
|
||||
@@ -157,6 +220,19 @@ describe("resolveRecordCapabilities", () => {
|
||||
canWrite: false,
|
||||
};
|
||||
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
|
||||
// The same posture for the lists: a scoped WRITE grant is writable only
|
||||
// where its list says so, not wherever its level would reach.
|
||||
const scopedWriter: AccountAccessEntry = {
|
||||
...WRITABLE,
|
||||
sections: ["labs"],
|
||||
writableDomains: ["labs"],
|
||||
};
|
||||
expect(resolveRecordCapabilities(scopedWriter).canWriteDomain("labs")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resolveRecordCapabilities(scopedWriter).canWriteDomain("measurements"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,8 +318,10 @@ describe("an unprovable record context withholds every control", () => {
|
||||
it("leaves every existing arm untouched when the two agree", () => {
|
||||
// The fourth argument defaults to false, so every case above this block
|
||||
// means exactly what it meant before the fence existed.
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual(
|
||||
resolveRecordCapabilities(READ_ONLY, false, false, false),
|
||||
);
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toMatchObject({
|
||||
...resolveRecordCapabilities(READ_ONLY, false, false, false),
|
||||
canWriteDomain: expect.any(Function),
|
||||
canManageDomain: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
const NEVER = () => false;
|
||||
const ALWAYS = () => true;
|
||||
|
||||
/**
|
||||
* What the person at the keyboard may do to the record on screen.
|
||||
@@ -20,7 +24,7 @@ import {
|
||||
* 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
|
||||
* ## The answers, and why there are several
|
||||
*
|
||||
* A grant at WRITE admits a short, closed list of verbs: entering readings,
|
||||
* lab results, an illness entry, a biomarker, a side effect, a medication, and
|
||||
@@ -32,34 +36,44 @@ import {
|
||||
* reason one release later: its only form sits on `/custom-metrics/{id}`,
|
||||
* which a switch closes too.
|
||||
* `sharing-surface-guard.test.ts` carries that argument
|
||||
* in full and is the list that binds; this paragraph has to agree with it. 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:
|
||||
* in full and is the list that binds; this paragraph has to agree with it.
|
||||
* Editing, deleting, restoring, purging, importing and bulk-acting are MANAGE
|
||||
* verbs, and so is every create the list does not name (a mood entry, a
|
||||
* screener, a cycle day). Which sections answer at which level is the
|
||||
* server's table (`domain-write-support.ts`), published per grant as two
|
||||
* lists on `accountAccess.active`. Four answers 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.
|
||||
* - `canWriteDomain(domain)` — is there a delegated write in this section
|
||||
* the grant satisfies.
|
||||
* - `canManageDomain(domain)` — may this person change what is already
|
||||
* there in this section, or add what a WRITE grant does not cover.
|
||||
* - `canManage` — the coarse switch: is there ANY section this person may
|
||||
* manage. The banner and the navigation ask it; a control never should,
|
||||
* because a control belongs to one section and the answer differs per
|
||||
* section (the vault is read-only under every grant).
|
||||
*
|
||||
* 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.
|
||||
* A surface that offers an admitted create asks `canAdd`. A control that
|
||||
* edits, deletes or creates what WRITE does not cover asks `canManageDomain`
|
||||
* for its own section. A control whose route resolves the caller rather than
|
||||
* the record (settings, credentials, AI budget, chart preferences) asks
|
||||
* `!inSharedRecord`: no grant reaches it at any level. None of these is a
|
||||
* permission decision made here: `canWrite` and the two lists arrive resolved
|
||||
* from the server on `accountAccess.active` and are bound, never recomputed
|
||||
* (`account-access-view.ts`). What this file decides is only which class of
|
||||
* affordance a resolved answer covers.
|
||||
*
|
||||
* ## The two facts this hook carries but does not yet act on
|
||||
* ## Why the lists are per section
|
||||
*
|
||||
* v1.37.0 gives a grant a section set and a third level, and both arrive here
|
||||
* (`sections`, `level`) as resolved values beside the booleans. Nothing in
|
||||
* this file turns either into a capability, and that is the state of the
|
||||
* release rather than an omission: the nav narrowing that reads `sections`
|
||||
* and the return of the edit and delete affordances that reads `level` are
|
||||
* the shared-session UX work, which lands after the routes that answer for a
|
||||
* MANAGE grant exist. Publishing the facts here first is what lets those be
|
||||
* a rendering change rather than a second access decision — and a control
|
||||
* that appeared before its route answered would be the failure the two
|
||||
* booleans above were written to end.
|
||||
* v1.37.0 published `level` and `sections` and answered `canManage: false`
|
||||
* for every shared record, a hold-back for the release where the level
|
||||
* arrived before the routes behind it. The routes landed and the hold-back
|
||||
* stayed, so a guardian of a managed profile saw no add, edit or delete
|
||||
* control on Mood, screeners, visits, allergies and labs while the server
|
||||
* accepted every one of those writes (#939). A single boolean could not have
|
||||
* closed that without opening the vault, whose routes accept none; the lists
|
||||
* close it section by section, and a section with no delegated route stays
|
||||
* closed by the same mechanism.
|
||||
*
|
||||
* ## Absent, not disabled
|
||||
*
|
||||
@@ -82,17 +96,35 @@ export interface RecordCapabilities {
|
||||
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. */
|
||||
/**
|
||||
* May the caller change what exists somewhere in this record, or add what
|
||||
* the delegation excludes. In one's own record, always. In a shared record,
|
||||
* true when `manageableDomains` is non-empty: the coarse switch the banner
|
||||
* and the navigation read. A control asks {@link canManageDomain} for its
|
||||
* own section instead, because the answer differs per section.
|
||||
*/
|
||||
canManage: boolean;
|
||||
/**
|
||||
* v1.38.12 — is there a delegated write in `domain` this grant satisfies.
|
||||
* Own record: always. Shared record: membership in
|
||||
* `accountAccess.active.writableDomains`. Refused, pending or unproven
|
||||
* context: never.
|
||||
*/
|
||||
canWriteDomain: (domain: ShareDomain) => boolean;
|
||||
/**
|
||||
* v1.38.12 — may the caller edit, delete or bulk-act in `domain`, or add
|
||||
* what a WRITE grant does not cover there. Own record: always. Shared
|
||||
* record: membership in `accountAccess.active.manageableDomains`. Refused,
|
||||
* pending or unproven context: never.
|
||||
*/
|
||||
canManageDomain: (domain: ShareDomain) => boolean;
|
||||
/**
|
||||
* v1.37.0 — the resolved level of the grant on screen, or `null` in one's
|
||||
* own record (which is not a grant and has no level).
|
||||
*
|
||||
* Bound from `accountAccess.active.level`, never derived. `canManage` is
|
||||
* deliberately NOT computed from it in this release: the edit and delete
|
||||
* affordances come back for `"manage"` when the routes that answer them
|
||||
* land, and a control that appears before its route answers is the exact
|
||||
* failure the two booleans above exist to prevent.
|
||||
* Bound from `accountAccess.active.level`, never derived, and not what the
|
||||
* controls read: the two domain lists are, because a level says what was
|
||||
* granted and the lists say which routes answer for it.
|
||||
*/
|
||||
level: AccountAccessEntry["level"] | null;
|
||||
/**
|
||||
@@ -136,6 +168,8 @@ export function resolveRecordCapabilities(
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
canWriteDomain: NEVER,
|
||||
canManageDomain: NEVER,
|
||||
level: null,
|
||||
sections: [],
|
||||
recordKind: "shared",
|
||||
@@ -147,6 +181,8 @@ export function resolveRecordCapabilities(
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: ALWAYS,
|
||||
canManageDomain: ALWAYS,
|
||||
// One's own record has no grant, so it has no level, and it is open in
|
||||
// full. Null on both counts rather than a fabricated "manage" over all
|
||||
// eight sections: the caller is not a delegate, and a consumer that had
|
||||
@@ -156,11 +192,19 @@ export function resolveRecordCapabilities(
|
||||
recordKind: "self",
|
||||
};
|
||||
}
|
||||
// Bound, not derived: the two lists are the server's intersection of the
|
||||
// grant with the routes that exist. Membership is the whole of the client's
|
||||
// part, and `canManage` is only the question "is either list non-empty"
|
||||
// asked of the manage one.
|
||||
const writable = new Set<ShareDomain>(active.writableDomains);
|
||||
const manageable = new Set<ShareDomain>(active.manageableDomains);
|
||||
return {
|
||||
inSharedRecord: true,
|
||||
canWrite: active.canWrite,
|
||||
canAdd: active.canWrite,
|
||||
canManage: false,
|
||||
canManage: manageable.size > 0,
|
||||
canWriteDomain: (domain) => writable.has(domain),
|
||||
canManageDomain: (domain) => manageable.has(domain),
|
||||
level: active.level,
|
||||
sections: active.sections,
|
||||
recordKind: active.recordKind,
|
||||
|
||||
Reference in new issue
Block a user