Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/error-codes.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 18:20:07 +02:00
commit 55ff6df205
87 files changed
+2080 -399

No files matched your search

@@ -14,8 +14,8 @@
* path is not proven at all. That leg lives in `e2e/account-sharing.spec.ts`.
*
* Mutation checks, run:
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
* read-only legs for the intake row and the card menu go red.
* - `useRecordCapabilities` answering `canWriteDomain` for every section →
* the read-only legs for the intake row and the card menu go 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.
@@ -31,8 +31,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
import type {
AccountAccess,
AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
import type { ShareDomain } from "@/lib/sharing/scope";
const OWNER = {
accountId: "acct-owner",
@@ -87,6 +91,44 @@ const MANAGING: AccountAccess = {
canSwitch: true,
};
/**
* A grant with a SCOPE, published the way the server publishes one.
*
* Every fixture above carries `sections: null`, and that is the hole the
* section-blind controls lived in: a WRITE grant scoped to one section answers
* `canWrite: true` like any other, so a control asking the coarse question was
* offered in sections the grant never opened. The two lists are the level ×
* scope × route-table intersection, derived here by the same function the
* server derives them with rather than by hand.
*/
function grant(
level: AccountAccessEntry["level"],
sections: ShareDomain[] | null,
): AccountAccess {
return {
accounts: [OWNER],
active: {
...OWNER,
access: level === "read" ? "read" : "write",
level,
sections,
canWrite: level !== "read",
writableDomains: delegatedDomains(level, sections, "write"),
manageableDomains: delegatedDomains(level, sections, "manage"),
},
canSwitch: true,
};
}
/** The write answer per section, for the pure helpers that take only that. */
function writeCaps(
level: AccountAccessEntry["level"],
sections: ShareDomain[] | null,
) {
const writable = new Set(delegatedDomains(level, sections, "write"));
return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) };
}
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
vi.mock("@/hooks/use-auth", () => ({
@@ -140,6 +182,9 @@ import { TodayHero } from "@/components/daily/today-hero";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { LedgerRowItem } from "@/components/medications/dose-history-ledger";
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
import { VisitsSection } from "@/components/encounters/visits-section";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { queryKeys } from "@/lib/query-keys";
import type { DailyDigest } from "@/lib/daily/digest";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
@@ -450,17 +495,13 @@ describe("linking a document to an illness episode", () => {
describe("the capture picker's kinds", () => {
const ALL = ["measurement", "medication", "mood"] as const;
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
const WRITER_CAPS = { canAdd: true, canManageDomain: () => false };
const READER_CAPS = { canAdd: false, canManageDomain: () => false };
/** The picker asks one question per kind, of that kind's own section. */
const caps = writeCaps;
const OWNER_CAPS = { canWriteDomain: () => true };
const WRITER_CAPS = caps("write", null);
const READER_CAPS = caps("read", null);
// A guardian: every section with a delegated route answers at MANAGE.
const GUARDIAN_CAPS = {
canAdd: true,
canManageDomain: (domain: string) =>
delegatedDomains("manage", null, "manage").includes(
domain as "measurements",
),
};
const GUARDIAN_CAPS = caps("manage", null);
it("offers everything in the caller's own record", () => {
expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([
@@ -490,6 +531,37 @@ describe("the capture picker's kinds", () => {
it("offers a read-only delegate nothing", () => {
expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]);
});
it("offers a scoped WRITE delegate only the kinds its sections cover", () => {
// The gap this closes. The coarse answer was the grant's LEVEL with no
// scope term, so a labs-scoped WRITE grant read as true and the picker
// offered a weight form and a dose the server refuses at
// `grantCoversDomain`.
expect(visibleCaptureKinds(caps("write", ["labs"]), [...ALL])).toEqual([]);
expect(
visibleCaptureKinds(caps("write", ["measurements"]), [...ALL]),
).toEqual(["measurement"]);
expect(
visibleCaptureKinds(caps("write", ["measurements", "medications"]), [
...ALL,
]),
).toEqual(["measurement", "medication"]);
});
it("offers a documents-scoped WRITE delegate nothing at all", () => {
// The worst case: the vault takes no delegated write at any level, so the
// grant's writable list is empty while `canWrite` still answers true.
expect(visibleCaptureKinds(caps("write", ["documents"]), [...ALL])).toEqual(
[],
);
});
it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => {
expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([
"mood",
]);
expect(visibleCaptureKinds(caps("manage", ["labs"]), [...ALL])).toEqual([]);
});
});
/* -------------------------------------------------------------------------- */
@@ -577,7 +649,7 @@ describe("a form opened before the record answered", () => {
});
it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => {
const DELEGATE = { canAdd: true, canManageDomain: () => false };
const DELEGATE = writeCaps("write", null);
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
@@ -586,25 +658,209 @@ describe("a form opened before the record answered", () => {
});
it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => {
const GUARDIAN = {
canAdd: true,
canManageDomain: (domain: string) => domain === "mind",
};
expect(admittedQuickEntry("mood", GUARDIAN)).toBe("mood");
expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood");
});
it("withdraws a sheet whose section the grant's scope leaves out", () => {
const LABS_ONLY = writeCaps("write", ["labs"]);
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, LABS_ONLY), sheet).toBe(null);
}
});
it("withdraws every quick-entry sheet from a read-only delegate", () => {
const READER = { canAdd: false, canManageDomain: () => false };
const READER = writeCaps("read", null);
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, READER), sheet).toBe(null);
}
});
it("leaves the owner's own sheets alone", () => {
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
const OWNER_CAPS = { canWriteDomain: () => true };
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
}
expect(admittedQuickEntry(null, OWNER_CAPS)).toBe(null);
});
});
/* -------------------------------------------------------------------------- */
/* The immunization log */
/* -------------------------------------------------------------------------- */
describe("the immunization log's add, edit and delete", () => {
const DOSE = {
id: "dose-1",
occurredAt: "2026-03-01T00:00:00.000Z",
antigenSlug: null,
vaccineName: "Tetanus",
doseNumber: null,
seriesDoses: null,
lotNumber: null,
site: null,
catalogEntry: null,
series: [],
practitioner: null,
encounter: null,
reminderId: null,
note: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
};
const seedList = (client: QueryClient) => {
client.setQueryData(queryKeys.vaccinationList(null), {
vaccinations: [DOSE],
});
};
/** The row carries the edit affordance only while it opens something. */
const rowIsTappable = (html: string) =>
html.includes('data-slot-open="vaccination-row-open"');
it("offers all three in the caller's own record", () => {
const html = render(OWN_RECORD, <VaccinationsView />, seedList);
expect(html).toContain('data-slot="vaccination-add"');
// The row opens the edit sheet, which is where the delete lives.
expect(rowIsTappable(html)).toBe(true);
});
it("offers a read-only delegate none of them", () => {
// `POST /api/vaccinations` is WRITE and everything else is MANAGE, so a
// READ delegate was being shown three controls and refused by all three.
const html = render(
grant("read", ["profile"]),
<VaccinationsView />,
seedList,
);
expect(html).not.toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html)).toBe(false);
});
it("offers a WRITE delegate the add and not the row", () => {
const html = render(
grant("write", ["profile"]),
<VaccinationsView />,
seedList,
);
expect(html).toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html), "editing and deleting a dose are MANAGE").toBe(
false,
);
});
it("offers a MANAGE delegate holding the section both", () => {
const html = render(
grant("manage", ["profile"]),
<VaccinationsView />,
seedList,
);
expect(html).toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html)).toBe(true);
});
it("offers a MANAGE delegate outside the section neither", () => {
// The page is presentable to a `profile` grant; a grant scoped elsewhere
// that still lands on the URL gets a log it can read and nothing else.
const html = render(
grant("manage", ["labs"]),
<VaccinationsView />,
seedList,
);
expect(html).not.toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html)).toBe(false);
});
it("offers the empty state's add on the same rule", () => {
const seedEmpty = (client: QueryClient) => {
client.setQueryData(queryKeys.vaccinationList(null), {
vaccinations: [],
});
};
expect(render(OWN_RECORD, <VaccinationsView />, seedEmpty)).toContain(
'data-slot="vaccination-add-empty"',
);
expect(
render(grant("read", ["profile"]), <VaccinationsView />, seedEmpty),
).not.toContain('data-slot="vaccination-add-empty"');
});
});
/* -------------------------------------------------------------------------- */
/* The visits list */
/* -------------------------------------------------------------------------- */
describe("a visit row's edit sheet", () => {
const VISIT: EncounterDTO = {
id: "visit-1",
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "ROUTINE",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
};
const seedList = (client: QueryClient) => {
client.setQueryData(
queryKeys.encounterList(null, null, undefined, undefined),
{
upcoming: [],
past: [VISIT],
},
);
};
/** The row's content is a button only while it opens something. */
const rowIsTappable = (html: string) =>
html.includes('data-slot="visit-card-open"');
it("opens for the owner", () => {
expect(rowIsTappable(render(OWN_RECORD, <VisitsSection />, seedList))).toBe(
true,
);
});
it("does not open for a READ or a WRITE delegate", () => {
// The add button was the only thing asked, and it asks the right question
// — `POST /api/encounters` is WRITE. The row underneath opens a sheet
// whose Save is `PATCH` and whose Delete is `DELETE`, both MANAGE.
for (const level of ["read", "write"] as const) {
const html = render(
grant(level, ["profile"]),
<VisitsSection />,
seedList,
);
expect(html, `${level} still sees the row`).toContain(
'data-slot="visit-card"',
);
expect(rowIsTappable(html), `${level} may open the sheet`).toBe(false);
}
});
it("keeps the add button for a WRITE delegate", () => {
// The two answers are different questions and must not collapse into one.
expect(
render(grant("write", ["profile"]), <VisitsSection />, seedList),
).toContain('data-slot="visits-add"');
expect(
render(grant("read", ["profile"]), <VisitsSection />, seedList),
).not.toContain('data-slot="visits-add"');
});
it("opens for a MANAGE delegate holding the section, and not one outside it", () => {
expect(
rowIsTappable(
render(grant("manage", ["profile"]), <VisitsSection />, seedList),
),
).toBe(true);
expect(
rowIsTappable(
render(grant("manage", ["labs"]), <VisitsSection />, seedList),
),
).toBe(false);
});
});
@@ -44,9 +44,21 @@ import { formatBytes } from "@/lib/format/bytes";
const FRESHNESS_STYLE: Record<OffhostBackupFreshness, string> = {
fresh: "border-success/30 bg-success/10 text-success",
due: "border-warning/30 bg-warning/10 text-warning",
stale: "border-destructive/30 bg-destructive/10 text-destructive",
never: "border-border bg-muted text-muted-foreground",
unknown: "border-border bg-muted text-muted-foreground",
stale: "border-destructive/30 bg-destructive/10 text-foreground",
never: "border-border bg-muted text-foreground",
unknown: "border-border bg-muted text-foreground",
};
/**
* `--destructive` as 12px badge text on its own wash measures 4.08:1 in the
* light theme and 3.99:1 in the dark one, and `--muted-foreground` on
* `bg-muted` 3.6:1 in the dark one, all under the AA floor. So the badge
* takes the shape the admin status rows already use: the token paints a small
* indicator and the wording stays in `text-foreground`. The word states the
* condition, so nothing hangs on the colour alone.
*/
const FRESHNESS_INDICATOR: Partial<Record<OffhostBackupFreshness, string>> = {
stale: "bg-destructive",
};
function freshnessLabel(
@@ -88,6 +100,16 @@ function AccountRow({ row }: { row: OffhostAccountRow }) {
variant="outline"
className={cn("shrink-0", FRESHNESS_STYLE[row.freshness])}
>
{FRESHNESS_INDICATOR[row.freshness] ? (
<span
aria-hidden="true"
data-slot="offhost-freshness-indicator"
className={cn(
"size-2 shrink-0 rounded-full",
FRESHNESS_INDICATOR[row.freshness],
)}
/>
) : null}
{freshnessLabel(row.freshness, t)}
</Badge>
</div>
@@ -14,7 +14,7 @@ vi.mock("next/navigation", () => ({
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
+27 -17
View File
@@ -36,13 +36,19 @@ export function DashboardHeader({
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
// v1.36.x — the dashboard quick-add offers three kinds; a delegation admits
// two of them (a reading, a dose). Mood stays with the account whose record
// it is, and the customize shortcut points at a settings page sharing does
// not cover at all.
const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities();
// The quick-add offers three kinds, and each one is asked about its own
// section: a reading under `measurements`, a dose under `medications`, a
// mood entry under `mind`. The coarse `canAdd` this used to gate the whole
// menu on carries the grant's LEVEL and no scope, so it answered true for a
// grant that opens neither of the two sections the menu never asked about.
// The customize shortcut points at a settings page sharing does not cover at
// all.
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const canAddIntake = canWriteDomain("medications");
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canManageDomain("mind");
const canAddMood = canWriteDomain("mind");
const canAddAnything = canAddMeasurement || canAddIntake || canAddMood;
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -117,7 +123,7 @@ export function DashboardHeader({
</Link>
</Button>
)}
{canAdd && (
{canAddAnything && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
@@ -152,10 +158,12 @@ export function DashboardHeader({
phase-A3 fix #1 hardened this with a unit guard at
`src/app/__tests__/quick-add-labels.test.ts` — both labels
must differ from each other AND from `common.add`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
{canAddMeasurement && (
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
)}
{canAddMood && (
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
@@ -167,12 +175,14 @@ export function DashboardHeader({
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
{canAddIntake && (
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -34,17 +34,7 @@ import type { ShareDomain } from "@/lib/sharing/scope";
export type QuickEntryDialog =
"measurement" | "mood" | "medicationIntake" | null;
/**
* v1.36.x — which of the three the delegation admits, and the same rule the
* capture picker states at length.
*
* A WRITE grant covers entering a reading and marking a dose. A mood entry is
* not an admitted verb and the server refuses it under a switch.
*/
const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
new Set(["measurement", "medicationIntake"]);
/** v1.38.12 — the section each sheet writes to; see the capture picker. */
/** The section each sheet writes to; see the capture picker. */
const QUICK_ENTRY_DOMAIN: Readonly<
Record<NonNullable<QuickEntryDialog>, ShareDomain>
> = {
@@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly<
*/
export function admittedQuickEntry(
open: QuickEntryDialog,
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain">,
): QuickEntryDialog {
if (open === null) return open;
if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open;
return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null;
return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null;
}
/**
+1 -2
View File
@@ -251,8 +251,7 @@ export function DocumentsView() {
const queryClient = useQueryClient();
const coachLaunch = useCoachLaunch();
const moduleEnabled =
inSharedRecord || user?.modules?.inboundDocuments === true;
const moduleEnabled = user?.modules?.inboundDocuments === true;
// UX redirect only — the API routes enforce the gate server-side.
useEffect(() => {
@@ -20,6 +20,16 @@
* saved and a plain sentence saying the checkup stayed open. That is reported
* rather than skipped in silence: somebody who believes a checkup was closed
* will not look at it again.
*
* Capability note: this sheet carries no capability check of its own, by
* decision. Save is a create on one mount and an edit on the other, and Delete
* exists only in edit mode — so the question belongs to whoever opens it.
* `VisitsSection` hands it a record only under `canManageDomain("profile")`,
* and the two mounts that can only create (`EpisodeVisitsCard`,
* `VorsorgeSection`) pass `encounter={null}` behind their own write check. A
* THIRD mount that hands this sheet a record without asking the manage
* question reintroduces an ungated Delete, and nothing here will fail. Ask at
* the caller, or move the check in here for everyone.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
+39 -7
View File
@@ -35,7 +35,14 @@ export function VisitCard({
onOpen,
}: {
encounter: Encounter;
onOpen: (encounter: Encounter) => void;
/**
* Opens the edit sheet. Omitted when the caller may not change the visit —
* `PATCH` and `DELETE /api/encounters/{id}` are both `("manage", "profile")`,
* so a READ or WRITE delegate gets the card as a card and not as a button.
* A row that reads as tappable and answers 403 is the affordance saying
* something about the grant that is not true.
*/
onOpen?: (encounter: Encounter) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
@@ -71,11 +78,7 @@ export function VisitCard({
className="gap-2 py-3 md:py-4"
>
<CardContent className="space-y-2">
<button
type="button"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
<Body onOpen={onOpen} encounter={encounter}>
<div className="flex items-start gap-2">
<CalendarClock
className="text-foreground mt-0.5 size-5 shrink-0"
@@ -120,8 +123,37 @@ export function VisitCard({
))}
</div>
) : null}
</button>
</Body>
</CardContent>
</Card>
);
}
/**
* The row's content, tappable or not.
*
* One element either way rather than two copies of the content: the read-only
* form keeps the same spacing the button had, so a card that loses its edit
* affordance does not also change shape.
*/
function Body({
onOpen,
encounter,
children,
}: {
onOpen?: (encounter: Encounter) => void;
encounter: Encounter;
children: React.ReactNode;
}) {
if (!onOpen) return <div className="w-full space-y-2">{children}</div>;
return (
<button
type="button"
data-slot="visit-card-open"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
{children}
</button>
);
}
+8 -4
View File
@@ -30,9 +30,13 @@ import { VisitCard } from "./visit-card";
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain } = useRecordCapabilities();
// `POST /api/encounters` answers at WRITE under the profile section.
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
// `POST /api/encounters` answers at WRITE under the profile section; the
// edit and the delete behind the row are both MANAGE. Only the add was
// asked, so every row stayed tappable into a sheet whose Save and Delete
// the server refuses.
const canAddVisit = canWriteDomain("profile");
const canManageVisits = canManageDomain("profile");
const list = useEncounters(enabled);
// `null` closed, `"new"` create, a row edits it.
@@ -109,7 +113,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
onOpen={canManageVisits ? openSheet : undefined}
/>
))}
</div>
@@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
onOpen={canManageVisits ? openSheet : undefined}
/>
))}
</div>
+4 -3
View File
@@ -320,7 +320,8 @@ 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, inSharedRecord } = useRecordCapabilities();
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddEpisode = canWriteDomain("illness");
const {
data: episodes,
isLoading,
@@ -412,7 +413,7 @@ export function IllnessView() {
)}
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
{canAdd && (
{canAddEpisode && (
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -476,7 +477,7 @@ export function IllnessView() {
description={t("illness.empty.body")}
ctaSize="lg"
action={
canAdd ? (
canAddEpisode ? (
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
+4 -3
View File
@@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const canManageLabs = canManageDomain("labs");
const { t, locale } = useTranslations();
const router = useRouter();
@@ -378,7 +379,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null}
{/* v1.36.x — entering a result is an admitted delegated write;
renaming or removing the marker is not. */}
{canAdd && (
{canAddLab && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
@@ -436,7 +437,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
canAdd ? (
canAddLab ? (
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
+3 -2
View File
@@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const { prefs } = useModuleListPrefs("labs");
// v1.22 — a short, factual line under each marker heading describing what the
@@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst && canAdd ? (
onAddFirst && canAddLab ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
@@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
@@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
+13 -22
View File
@@ -51,24 +51,18 @@ import {
type CaptureKind = "measurement" | "medication" | "mood";
/**
* v1.36.x — which of the capture surfaces a delegation admits.
* The section each capture surface writes to.
*
* A WRITE grant covers entering a reading and marking a dose. It does not
* cover a mood entry: that verb stays on the owner's own authentication and
* the server refuses it under a switch. The picker is the fastest path into
* all three on a phone, so it is the surface where offering an unadmitted one
* costs the most.
*/
const DELEGABLE_CAPTURE_KINDS: ReadonlySet<CaptureKind> = new Set([
"measurement",
"medication",
]);
/**
* v1.38.12 — the section each capture surface writes to. A MANAGE grant that
* reaches the section's routes is offered the surface; a mood entry is a
* MANAGE create under `mind`, so a guardian gets it and a WRITE delegate does
* not.
* One question per kind, asked of that kind's own section. The picker used to
* ask a coarse one as well — a closed list of kinds a WRITE grant admits,
* offered whenever the grant could write anywhere — and that answer is the
* grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]`
* read as true and got a weight form the server refuses; one scoped to
* `["documents"]`, which takes no delegated write at any level, got both.
*
* `writableDomains` is already the level × scope × route-table intersection,
* so asking it per section answers both cases and the mood entry's
* MANAGE-only create as well.
*/
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
measurement: "measurements",
@@ -117,13 +111,10 @@ export function admittedCaptureKind(
* cannot tap the button that opens it.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain">,
kinds: ReadonlyArray<CaptureKind>,
): CaptureKind[] {
return kinds.filter((kind) => {
if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true;
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
});
return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind]));
}
interface CapturePickerProps {
+22 -5
View File
@@ -224,11 +224,28 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-documents",
requiresModule: "inboundDocuments",
},
// v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they
// drop out under a switch. Both are AI surfaces, and AI egress of a
// person's health data rides the consent THAT person gave for their own
// use; a delegate triggering it would create a consent-shaped act the owner
// never made. Non-delegable in v1, server-side and here (design §4).
// Insights and the Coach carry no `sharedRecord` flag, so they drop out
// under a switch. The reason has changed since v1.36.0 and the old wording
// ("non-delegable, server-side and here") is no longer true of the tiles:
// twenty-seven `/api/insights/*` reads plus `/api/dashboard/summary` and
// `/api/export/health-record` declare `("manage", "record")`, and the
// AI-egress objection they were closed for is answered by construction —
// the record resolver stamps `setDelegatedGenerationSuppressed`, so a
// delegate reads what the owner's own account generated and causes no
// egress.
//
// What keeps the door shut is the OVERVIEW's own reads rather than the
// tiles': `/api/insights/layout` (`insights/layout/route.ts`) and
// `/api/insights/generate` (`insights/generate/route.ts`) both resolve
// `requireAuth()`, which refuses any switch at any level. Opening the entry
// without deciding what those two mean for a delegate — whose saved layout,
// and whether the briefing reads or regenerates — would hand a MANAGE
// delegate a page whose chrome 403s while its tiles answer. That is a
// decision about two more routes, not a flag on this one.
//
// The Coach stays closed on the original argument, unchanged:
// `/api/insights/chat` is genuinely not delegable, and the page is a
// generation surface rather than a read of one.
{
href: "/insights",
tKey: "nav.insights",
@@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
@@ -292,8 +292,9 @@ export function MeasurementList({
// v1.36.x — inside somebody else's record, only the add path survives:
// entering a reading is an admitted delegated write, editing and deleting
// one are not. The row controls are dropped rather than disabled.
const { canAdd, canManage, inSharedRecord, canWrite } =
const { canWriteDomain, canManage, inSharedRecord, canWrite } =
useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const activeRecord = user?.accountAccess?.active ?? null;
// Named only when the sentence below is owed: a delegate who may add but
// not change. In the caller's own record, and for a read-only delegate who
@@ -1068,7 +1069,7 @@ export function MeasurementList({
>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst && canAdd ? (
) : onAddFirst && canAddMeasurement ? (
<Button onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
@@ -36,8 +36,9 @@ export function MedicationIntakeActions({
// v1.36.x — marking a dose taken or skipped is the verb the delegation was
// written for. A read-only delegate sees the card and its schedule and no
// action row; the card is otherwise unchanged.
const { canAdd } = useRecordCapabilities();
if (!canAdd) return null;
const { canWriteDomain } = useRecordCapabilities();
const canRecordIntake = canWriteDomain("medications");
if (!canRecordIntake) return null;
return (
<div className="flex gap-2">
@@ -175,7 +175,7 @@ export function LedgerAddDialog({
// confirmation names the record, because "Taken" alone is the one
// confirmation a person acting for somebody else does not need. The
// ledger's "+ Eintrag" button that opens this dialog is gated on
// `canAdd`, so a delegate reaches it.
// `canWriteDomain("medications")`, so a delegate reaches it.
const toastOptions = intakeToastOptions({
recordName,
eventId: undefined,
@@ -173,7 +173,8 @@ export function DoseHistoryLedger({
// v1.36.x — a delegate may record a dose, which is what "add" and the
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
// existing event are the owner's, so the row menu is absent for them.
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
// The record this ledger's writes land in, when it is not the caller's own.
// The two sibling intake paths in `use-medication-intake.ts` already drop
// Undo and name the record; this third one builds its own toast and missed
@@ -434,7 +435,7 @@ export function DoseHistoryLedger({
{t("medications.detail.verlauf.summaryEmpty")}
</p>
)}
{canAdd && (
{canAddIntake && (
<Button
variant="outline"
size="sm"
@@ -598,7 +599,8 @@ export function LedgerRowItem({
onUnpin: (eventId: string) => void;
}) {
const { t } = useTranslations();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
@@ -705,7 +707,7 @@ export function LedgerRowItem({
</div>
<div className="flex shrink-0 items-center gap-1">
{showTakenButton && canAdd && (
{showTakenButton && canAddIntake && (
<Button
variant="outline"
size="sm"
@@ -734,7 +736,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 && !canManageMedications && (
{showTakenButton && canAddIntake && !canManageMedications && (
<Button
variant="outline"
size="sm"
@@ -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, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const showSideEffect = onLogSideEffect != null && canAdd;
const showSideEffect = onLogSideEffect != null && canAddSideEffect;
if (!canManageMedications && !showSideEffect) return null;
return (
@@ -407,7 +407,8 @@ function MedicationTableRowItem({
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
// without its action pair.
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
// SAME batched compliance source as the cards.
const { data: compliance } = useMedicationComplianceSummary(medication.id);
@@ -685,7 +686,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active && canAdd ? (
{medication.active && canAddIntake ? (
<div className="flex gap-1.5">
<Button
size="icon"
@@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
const queryClient = useQueryClient();
@@ -250,7 +251,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
)}
{/* v1.36.x — noting a side effect is an admitted delegated write.
Removing one that is already noted is not. */}
{canAdd && (
{canAddSideEffect && (
<Button
size="sm"
variant="outline"
@@ -157,6 +157,10 @@ function DoseRow({
<Card
className="hover:bg-muted/40 gap-0 transition-colors"
data-slot="vaccination-row"
// A second, stable marker for the state rather than for the row: the
// `role` is markup that could legitimately change, and a guard matching
// on it would pass the day a real `<button>` replaced the card.
data-slot-open={onEdit ? "vaccination-row-open" : undefined}
data-vaccination-id={record.id}
onClick={onEdit ? () => onEdit(record) : undefined}
role={onEdit ? "button" : undefined}
@@ -10,6 +10,13 @@
* On a successful create the caller may raise the booster-mint prompt; this
* sheet completes and closes regardless of what the person does with it. The
* mint is an offer, never a step.
*
* Capability note: the same posture as the visit sheet. No check here; Delete
* renders only in edit mode, and the one mount that can reach edit mode
* (`VaccinationsView`) asks `canManageDomain("profile")` before handing over a
* record, while its create path asks `canWriteDomain("profile")`. A second
* mount that skips either question reintroduces an ungated control with
* nothing failing.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
@@ -18,6 +18,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { VaccinationList } from "./vaccination-list";
@@ -27,6 +28,19 @@ import { useVaccinations, type Vaccination } from "./use-vaccinations";
export function VaccinationsView() {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
// `POST /api/vaccinations` answers at WRITE; the edit, the delete and the
// restore behind it are all MANAGE. The page is presentable to every
// `profile`-scoped grant, so without this a READ delegate was shown add,
// edit and delete, and every one of them was refused by a server that was
// right to refuse. Same split, same domain, same shape as the address book
// next door.
const canAddDose = canWriteDomain("profile");
const canManageProfile = canManageDomain("profile");
// Planning the booster mints a `MeasurementReminder`, which lives in the
// measurements section rather than this one. A grant that opened only the
// health background is refused there, so the offer is not made.
const canPlanBooster = canWriteDomain("measurements");
const { data, isLoading, isError, refetch } = useVaccinations();
const records = data?.vaccinations ?? [];
@@ -50,6 +64,17 @@ export function VaccinationsView() {
setSheetOpen(true);
};
const addButton = canAddDose ? (
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
) : null;
return (
<div className="space-y-6">
<PageHeader
@@ -59,16 +84,7 @@ export function VaccinationsView() {
</span>
}
description={t("vaccinations.subtitle")}
actions={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
actions={addButton}
/>
<VaccinationSheet
@@ -79,7 +95,8 @@ export function VaccinationsView() {
onCreated={(created) => {
// Offer the booster only when the catalogue entry carries an
// interval; a free-text or one-off dose never prompts.
if (boosterOfferFor(created)) setBoosterFor(created);
if (canPlanBooster && boosterOfferFor(created))
setBoosterFor(created);
}}
/>
@@ -102,21 +119,26 @@ export function VaccinationsView() {
onRetry={() => void refetch()}
/>
) : records.length > 0 ? (
<VaccinationList records={records} onEdit={openEdit} />
<VaccinationList
records={records}
onEdit={canManageProfile ? openEdit : undefined}
/>
) : (
<EmptyState
icon={<Syringe className="size-6" />}
title={t("vaccinations.empty.title")}
description={t("vaccinations.empty.description")}
action={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
canAddDose ? (
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
) : undefined
}
/>
)}