mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user