mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user