Merge pull request #709 from MBombeck/work/dw-e

Stop offering a delegate buttons the server will refuse
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-03 12:59:04 +02:00
commit 326db73883
62 files changed
+2435 -748

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
/**
* A delegate who may write: what they are offered, and what they are not.
*
* The unit suite runs SSR-only, so it holds the PAINT and never the click: it
* can prove an add button is absent, which is the property that matters most,
* and it cannot prove that the one still standing submits. This spec is the
* other half — a real form, a real POST, a real row in somebody else's record,
* and the owner seeing it afterwards.
*
* ## Why it gates itself instead of seeding a WRITE grant directly
*
* The grant level is chosen in the invitation form, which is another chunk's
* work. Rather than mint a WRITE row behind the UI's back — which would prove
* the journey works for a grant no person can create — the journey looks for
* the level control and stands down when it is not there yet.
*
* **To enable this once the invite form ships its level control:** if it lands
* under a different `data-slot` than the constant below, change that one
* string. Nothing else in this file assumes anything about the control except
* that picking WRITE and submitting mints a WRITE grant.
*
* The skip is deliberately loud rather than silent: it names the missing
* control, so a run where the control exists and the journey still does not
* execute reads as a bug in this file rather than as an absence upstream.
*
* ## What this spec cannot cover
*
* The owner's activity view is asserted at the level of "a row appeared for
* something the delegate did", not at the level of the per-verb sentence. The
* verb lines live in `src/lib/record-activity/activity-verb.ts` with their own
* unit test; the one line that binds them into the activity card belongs to
* the file this chunk was told not to touch.
*/
import type { BrowserContext, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
DELEGATE_STORAGE_STATE_PATH,
E2E_OWNER,
E2E_USER,
OWNER_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
/**
* The invitation form's grant-level control. The journey runs when this is on
* the page and stands down when it is not. One string, one place.
*/
const GRANT_LEVEL_SLOT = "grant-invite-level";
/** The value the level control carries for a grant that may add entries. */
const WRITE_LEVEL_VALUE = "write";
test.describe("delegated writes", () => {
// One journey in order, like the read-only sibling: each step is the next
// one's precondition, and the invitation endpoint is rate-limited.
test.describe.configure({ mode: "serial" });
// The `page` fixture is the DELEGATE throughout, on this journey's own
// cookie jar — the switch is stamped on the session row, so switching a
// shared jar would switch it for every spec holding the same cookie.
test.use({ storageState: DELEGATE_STORAGE_STATE_PATH });
// Desktop only: the journey mutates shared rows between two seeded accounts,
// so two projects running it at once race each other and the second
// invitation is refused as a duplicate.
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"shared-fixture journey — runs once",
);
});
let ownerContext: BrowserContext;
let ownerPage: Page;
let levelControlPresent = false;
test.beforeAll(async ({ browser }) => {
ownerContext = await browser.newContext({
storageState: OWNER_STORAGE_STATE_PATH,
});
ownerPage = await ownerContext.newPage();
await ownerPage.goto("/settings/access");
levelControlPresent =
(await ownerPage.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`).count()) >
0;
});
test.afterAll(async () => {
await ownerContext.close();
});
test.beforeEach(() => {
test.skip(
!levelControlPresent,
`no [data-slot="${GRANT_LEVEL_SLOT}"] in the invitation form — a WRITE grant cannot be created through the UI yet`,
);
});
test("the owner invites at a level that may add entries", async () => {
await ownerPage.goto("/settings/access");
const identifier = ownerPage.locator(
'[data-slot="grant-invite-identifier"]',
);
const submit = ownerPage.locator('[data-slot="grant-invite-submit"]');
// The controlled input keeps the submit disabled until React has attached,
// so retry the pair rather than waiting a fixed time and hoping.
await expect(async () => {
await identifier.fill(E2E_USER.username);
await expect(submit).toBeEnabled({ timeout: 1000 });
}).toPass({ timeout: 15_000 });
await ownerPage
.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`)
.selectOption(WRITE_LEVEL_VALUE);
// Read the posted body: a level control that renders and sends a hardcoded
// level would pass every render assertion and ship a read-only grant.
const invitePost = ownerPage.waitForRequest(
(req) =>
req.method() === "POST" && req.url().endsWith("/api/account/grants"),
);
await submit.click();
const posted = JSON.parse((await invitePost).postData() ?? "{}") as {
access?: string;
};
expect(
posted.access?.toLowerCase(),
"the invitation must carry the level the owner chose",
).toBe(WRITE_LEVEL_VALUE);
});
test("the delegate accepts and opens the record", async ({ page }) => {
await page.goto("/settings/access");
await page.locator('[data-slot="grant-accept"]').first().click();
// The switcher lives inside the user menu, like its read-only sibling.
await page.getByRole("button", { name: "User menu" }).first().click();
await page.locator('[data-slot="account-switcher-trigger"]').click();
await expect(
page.locator('[data-slot="account-switcher-menu"]'),
).toBeVisible();
await page.locator('[data-slot="account-switcher-entry"]').click();
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toBeVisible();
await expect(banner).toContainText(E2E_OWNER.username);
// The banner drops its read-only clause on its own once the level says so.
await expect(banner).not.toContainText("read it, not change it");
});
test("the reading they add lands in the owner's record", async ({ page }) => {
await page.goto("/measurements");
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
// The add path survives at WRITE. This is the click an SSR test cannot
// make: the button rendering and the button working are two facts.
const post = page.waitForResponse(
(res) =>
res.request().method() === "POST" &&
res.url().endsWith("/api/measurements"),
);
await page.getByRole("button", { name: /add measurement/i }).click();
await page.locator('input[name="value"], #value').first().fill("71.5");
await page.getByRole("button", { name: /^save$/i }).click();
expect((await post).status(), "the write must be accepted").toBeLessThan(
300,
);
// And the row it created is not theirs to change. Absent, not disabled:
// a `toBeDisabled()` assertion here would pass against the exact design
// this release exists to avoid.
await expect(
page.locator('[data-slot="selection-action-bar"]'),
).toHaveCount(0);
await expect(page.getByRole("button", { name: /^delete$/i })).toHaveCount(
0,
);
});
test("the owner sees that somebody else was in their record", async () => {
await ownerPage.goto("/settings/access");
const rows = ownerPage.locator('[data-slot="record-activity-row"]');
await expect(rows.first()).toBeVisible();
await expect(rows.first()).toContainText(E2E_USER.username);
});
});
+25
View File
@@ -9489,6 +9489,31 @@
"opened": "{name} hat deine Akte {count}× geöffnet",
"acted": "{name} hat etwas in deiner Akte geändert",
"deletedAccount": "Ein gelöschtes Konto"
},
"delegate": {
"addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}."
},
"lookingAfter": {
"title": "Ich kümmere mich um",
"description": "Akten, die andere mit dir geteilt haben.",
"levelRead": "Darf lesen",
"levelWrite": "Darf lesen und hinzufügen",
"open": "Akte von {name} öffnen"
},
"toast": {
"savedTo": "In der Akte von {name} gespeichert."
},
"activityVerb": {
"measurementCreate": "{name} hat einen Messwert eingetragen",
"labResultCreate": "{name} hat einen Laborwert eingetragen",
"allergyCreate": "{name} hat eine Allergie eingetragen",
"familyHistoryCreate": "{name} hat einen Eintrag zur Familienanamnese angelegt",
"illnessEpisodeCreate": "{name} hat eine Erkrankung angelegt",
"biomarkerCreate": "{name} hat einen Biomarker angelegt",
"customMetricEntryCreate": "{name} hat einen eigenen Messwert eingetragen",
"medicationSideEffectCreate": "{name} hat eine Nebenwirkung notiert",
"medicationCreate": "{name} hat ein Medikament angelegt",
"medicationIntake": "{name} hat eine Dosis eingetragen"
}
}
}
+25
View File
@@ -9489,6 +9489,31 @@
"opened": "{name} opened your record {count}×",
"acted": "{name} made a change in your record",
"deletedAccount": "A deleted account"
},
"delegate": {
"addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do."
},
"lookingAfter": {
"title": "Looking after",
"description": "Records other people have shared with you.",
"levelRead": "Can view",
"levelWrite": "Can view and add",
"open": "Open {name}'s record"
},
"toast": {
"savedTo": "Saved to {name}'s record."
},
"activityVerb": {
"measurementCreate": "{name} added a reading",
"labResultCreate": "{name} added a lab result",
"allergyCreate": "{name} added an allergy",
"familyHistoryCreate": "{name} added a family history entry",
"illnessEpisodeCreate": "{name} added an illness entry",
"biomarkerCreate": "{name} added a biomarker",
"customMetricEntryCreate": "{name} logged a tracked value",
"medicationSideEffectCreate": "{name} noted a side effect",
"medicationCreate": "{name} added a medication",
"medicationIntake": "{name} marked a dose"
}
}
}
+25
View File
@@ -9489,6 +9489,31 @@
"opened": "{name} abrió tu historial {count}×",
"acted": "{name} hizo un cambio en tu historial",
"deletedAccount": "Una cuenta eliminada"
},
"delegate": {
"addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}."
},
"lookingAfter": {
"title": "Estoy cuidando de",
"description": "Historiales que otras personas han compartido contigo.",
"levelRead": "Puede ver",
"levelWrite": "Puede ver y añadir",
"open": "Abrir el historial de {name}"
},
"toast": {
"savedTo": "Guardado en el historial de {name}."
},
"activityVerb": {
"measurementCreate": "{name} añadió una medición",
"labResultCreate": "{name} añadió un resultado de laboratorio",
"allergyCreate": "{name} añadió una alergia",
"familyHistoryCreate": "{name} añadió un antecedente familiar",
"illnessEpisodeCreate": "{name} añadió una enfermedad",
"biomarkerCreate": "{name} añadió un biomarcador",
"customMetricEntryCreate": "{name} registró un valor propio",
"medicationSideEffectCreate": "{name} anotó un efecto secundario",
"medicationCreate": "{name} añadió un medicamento",
"medicationIntake": "{name} registró una dosis"
}
}
}
+25
View File
@@ -9489,6 +9489,31 @@
"opened": "{name} a ouvert votre dossier {count}×",
"acted": "{name} a fait une modification dans votre dossier",
"deletedAccount": "Un compte supprimé"
},
"delegate": {
"addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées."
},
"lookingAfter": {
"title": "Je m'occupe de",
"description": "Les dossiers que d'autres personnes partagent avec vous.",
"levelRead": "Peut consulter",
"levelWrite": "Peut consulter et ajouter",
"open": "Ouvrir le dossier de {name}"
},
"toast": {
"savedTo": "Enregistré dans le dossier de {name}."
},
"activityVerb": {
"measurementCreate": "{name} a ajouté une mesure",
"labResultCreate": "{name} a ajouté un résultat de laboratoire",
"allergyCreate": "{name} a ajouté une allergie",
"familyHistoryCreate": "{name} a ajouté un antécédent familial",
"illnessEpisodeCreate": "{name} a ajouté une maladie",
"biomarkerCreate": "{name} a ajouté un biomarqueur",
"customMetricEntryCreate": "{name} a enregistré une valeur personnalisée",
"medicationSideEffectCreate": "{name} a noté un effet indésirable",
"medicationCreate": "{name} a ajouté un médicament",
"medicationIntake": "{name} a enregistré une prise"
}
}
}
+25
View File
@@ -9489,6 +9489,31 @@
"opened": "{name} ha aperto la tua cartella {count}×",
"acted": "{name} ha fatto una modifica nella tua cartella",
"deletedAccount": "Un account eliminato"
},
"delegate": {
"addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle."
},
"lookingAfter": {
"title": "Mi prendo cura di",
"description": "Cartelle che altre persone hanno condiviso con te.",
"levelRead": "Può consultare",
"levelWrite": "Può consultare e aggiungere",
"open": "Apri la cartella di {name}"
},
"toast": {
"savedTo": "Salvato nella cartella di {name}."
},
"activityVerb": {
"measurementCreate": "{name} ha aggiunto una misurazione",
"labResultCreate": "{name} ha aggiunto un risultato di laboratorio",
"allergyCreate": "{name} ha aggiunto un'allergia",
"familyHistoryCreate": "{name} ha aggiunto una voce di anamnesi familiare",
"illnessEpisodeCreate": "{name} ha aggiunto una malattia",
"biomarkerCreate": "{name} ha aggiunto un biomarcatore",
"customMetricEntryCreate": "{name} ha registrato un valore personalizzato",
"medicationSideEffectCreate": "{name} ha annotato un effetto collaterale",
"medicationCreate": "{name} ha aggiunto un farmaco",
"medicationIntake": "{name} ha registrato una dose"
}
}
}
+25
View File
@@ -9489,6 +9489,31 @@
"opened": "{name} otworzył twoją kartę {count}×",
"acted": "{name} wprowadził zmianę w twojej karcie",
"deletedAccount": "Usunięte konto"
},
"delegate": {
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
},
"lookingAfter": {
"title": "Opiekuję się",
"description": "Karty, które udostępniły ci inne osoby.",
"levelRead": "Może przeglądać",
"levelWrite": "Może przeglądać i dodawać",
"open": "Otwórz kartę użytkownika {name}"
},
"toast": {
"savedTo": "Zapisano w karcie użytkownika {name}."
},
"activityVerb": {
"measurementCreate": "{name} dodał pomiar",
"labResultCreate": "{name} dodał wynik badania",
"allergyCreate": "{name} dodał alergię",
"familyHistoryCreate": "{name} dodał wpis w wywiadzie rodzinnym",
"illnessEpisodeCreate": "{name} dodał chorobę",
"biomarkerCreate": "{name} dodał biomarker",
"customMetricEntryCreate": "{name} zapisał własny pomiar",
"medicationSideEffectCreate": "{name} zanotował działanie niepożądane",
"medicationCreate": "{name} dodał lek",
"medicationIntake": "{name} zapisał przyjęcie dawki"
}
}
}
@@ -256,7 +256,7 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 2,
},
"src/components/medications/take-all-due.ts": { "toast.success": 1 },
"src/components/medications/use-medication-intake.ts": { "toast.success": 4 },
"src/components/medications/use-medication-intake.ts": { "toast.success": 5 },
"src/components/medications/wizard/medication-wizard-dialog.tsx": {
"toast.success": 1,
},
+24 -16
View File
@@ -30,12 +30,18 @@ import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicato
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function LabsPage() {
const { user, isAuthenticated, isLoading } = useAuth();
// v1.36.x — entering a lab result by hand is an admitted delegated write.
// Scanning a report is not: it files a document and commits through the OCR
// route, and neither is delegated. So a WRITE delegate keeps the manual add
// and loses the choice menu around it.
const { canAdd, canManage } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -94,27 +100,29 @@ export default function LabsPage() {
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Link
href="/settings/layout/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available ? (
{ocrCapability.data?.available && canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
@@ -133,7 +141,7 @@ export default function LabsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
) : canAdd ? (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -141,7 +149,7 @@ export default function LabsPage() {
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
) : null}
</>
}
/>
+17 -10
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import {
MeasurementForm,
@@ -35,6 +36,7 @@ export function resolveMeasurementReturnTo(
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const { canAdd } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -149,17 +151,22 @@ export default function MeasurementsPage() {
</span>
}
description={t("measurements.subtitle")}
// v1.36.x — entering a reading is one of the verbs a WRITE grant
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
<Button
className="min-h-11 sm:min-h-9"
onClick={() => {
setReturnTo(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
canAdd ? (
<Button
className="min-h-11 sm:min-h-9"
onClick={() => {
setReturnTo(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
) : null
}
/>
+51 -39
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
@@ -159,6 +160,11 @@ function MedicationCardSkeleton() {
export default function MedicationsPageClient() {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
// v1.36.x — adding a medication and marking a dose are both admitted
// delegated writes, so a WRITE delegate keeps the Add menu whole. The
// take-all-due sweep is not (it rides the bulk intake route), and neither is
// the customize page, which sharing does not cover at all.
const { canAdd, canManage } = useRecordCapabilities();
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
@@ -417,7 +423,7 @@ export default function MedicationsPageClient() {
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
{dueMeds.length >= 2 && (
{canManage && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
@@ -433,47 +439,51 @@ export default function MedicationsPageClient() {
Same glyph, slot (left of the add button) and responsive
44-px mobile tap floor as the dashboard and insights
headers. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
(incl. a backdated one) against an existing medication, or
create a new medication. v1.12.2 — match the dashboard Add
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@@ -511,10 +521,12 @@ export default function MedicationsPageClient() {
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
</Button>
canAdd ? (
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
</Button>
) : undefined
}
/>
) : tableView ? (
+14 -7
View File
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { MoodForm } from "@/components/mood/mood-form";
@@ -20,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { user, isAuthenticated, isLoading } = useAuth();
const { canManage } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -95,13 +97,18 @@ export default function MoodPage() {
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
{/* v1.36.x — a mood entry is not one of the verbs a grant
admits, so it stays with the account whose record it is. A
delegate at any level gets no add path here. */}
{canManage && (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
)}
</>
}
/>
+19 -7
View File
@@ -1,5 +1,7 @@
"use client";
import { LookingAfterCard } from "@/components/dashboard/looking-after-card";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
@@ -241,6 +243,7 @@ export default function DashboardPageClient({
value: number | null | undefined,
): number | null =>
value == null ? null : unitDisplay.toDisplayDelta(type, value);
const { canAdd } = useRecordCapabilities();
const [quickEntryDialog, setQuickEntryDialog] =
useState<QuickEntryDialog>(null);
@@ -901,6 +904,13 @@ export default function DashboardPageClient({
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{/* v1.36.x — the caregiver's front door, directly under the greeting
and above this account's own day. Somebody who opens the app to
check on a parent should not have to go through settings to get
there. Renders nothing for an account nobody has shared with, and
nothing while a switch is already on. */}
<LookingAfterCard />
{/* S2 — the Today hero, promoted above the tile strip (MARC sign-off
decision 1). Renders the S1 daily digest: the day's read, the
honest score/freshness face, and the worth-a-look rail. Shimmer
@@ -1976,13 +1986,15 @@ export default function DashboardPageClient({
title={t("dashboard.emptyTitle")}
description={t("dashboard.emptyDescription")}
action={
<Button
size="sm"
onClick={() => setQuickEntryDialog("measurement")}
>
<Plus className="h-4 w-4" />
{t("dashboard.emptyAddMeasurement")}
</Button>
canAdd ? (
<Button
size="sm"
onClick={() => setQuickEntryDialog("measurement")}
>
<Plus className="h-4 w-4" />
{t("dashboard.emptyAddMeasurement")}
</Button>
) : undefined
}
/>
);
@@ -0,0 +1,198 @@
/**
* What a delegate is offered, rendered rather than described.
*
* v1.36.0 shipped sharing read-only and gated it only in the application
* chrome. Every feature surface still painted its add button, and a delegate
* who tapped one met a 403 from a server that was right to refuse. These
* render the real controls to markup and assert the paint, on the three
* capability states a session can be in.
*
* The suite is SSR-only (`@testing-library/react` is not a dependency here),
* which bounds what it can prove: it holds the RENDER, never the click. A
* control that is absent here cannot be tapped, and that is the whole property
* — but a control that is present here is not proven to work, and a submit
* 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.
* - `DeleteButton` dropping its `canManage` bail → "no row delete inside
* somebody else's record" goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
const OWNER = {
accountId: "acct-owner",
username: "owner",
displayName: "Margarethe",
access: "read" as const,
canWrite: false,
};
const OWN_RECORD: AccountAccess = {
accounts: [OWNER],
active: null,
canSwitch: true,
};
const READ_ONLY: AccountAccess = {
accounts: [OWNER],
active: OWNER,
canSwitch: true,
};
const WRITABLE: AccountAccess = {
accounts: [OWNER],
active: { ...OWNER, access: "write", canWrite: true },
canSwitch: true,
};
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "delegate",
username: "delegate",
email: null,
role: "USER",
avatarUrl: null,
modules: {},
accountAccess: mockAccessRef.value,
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { DeleteButton } from "@/components/data-list/delete-button";
import { SelectionActionBar } from "@/components/data-list/selection-action-bar";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
import { visibleCaptureKinds } from "@/components/layout/capture-picker";
function render(access: AccountAccess, node: React.ReactNode): string {
mockAccessRef.value = access;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("row delete", () => {
const node = (
<DeleteButton 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", () => {
// 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("");
});
});
describe("bulk selection bar", () => {
const node = (
<SelectionActionBar
count={3}
onClear={() => {}}
onConfirmDelete={() => {}}
isDeleting={false}
confirmTitle="Delete 3?"
confirmBody="Gone."
/>
);
it("renders in the caller's own record", () => {
expect(render(OWN_RECORD, node)).toContain(
'data-slot="selection-action-bar"',
);
});
it("is absent inside somebody else's record", () => {
expect(render(WRITABLE, node)).toBe("");
});
});
describe("marking a dose", () => {
const node = (
<MedicationIntakeActions intakeLoading={null} onRecordIntake={() => {}} />
);
it("is offered to a delegate who may write", () => {
// The verb the delegation was written for: somebody looking after a
// parent marks the morning dose taken.
expect(render(WRITABLE, node)).toContain("<button");
});
it("is absent for a read-only delegate", () => {
expect(render(READ_ONLY, node)).toBe("");
});
it("is offered in the caller's own record", () => {
expect(render(OWN_RECORD, node)).toContain("<button");
});
});
describe("the medication card menu", () => {
const node = (
<MedicationCardMenu
onEdit={() => {}}
onOpenHistory={() => {}}
onLogSideEffect={() => {}}
/>
);
it("renders its trigger in the caller's own record", () => {
expect(render(OWN_RECORD, node)).toContain("<button");
});
it("keeps a trigger for a delegate who may write", () => {
// One item survives for them — noting a side effect — so the menu stays.
expect(render(WRITABLE, node)).toContain("<button");
});
it("disappears entirely for a read-only delegate", () => {
// Every item in it is owner work, so the trigger goes with them rather
// than opening onto an empty sheet.
expect(render(READ_ONLY, node)).toBe("");
});
});
describe("the capture picker's kinds", () => {
const ALL = ["measurement", "medication", "mood", "water"] as const;
it("offers everything in the caller's own record", () => {
expect(
visibleCaptureKinds({ canAdd: true, canManage: true }, true, [...ALL]),
).toEqual(["measurement", "medication", "mood", "water"]);
});
it("offers a delegate only what the delegation admits", () => {
// A reading and a dose are admitted verbs. A mood entry and a glass of
// water are not, and the server refuses them under a switch.
expect(
visibleCaptureKinds({ canAdd: true, canManage: false }, true, [...ALL]),
).toEqual(["measurement", "medication"]);
});
it("offers a read-only delegate nothing", () => {
expect(
visibleCaptureKinds({ canAdd: false, canManage: false }, true, [...ALL]),
).toEqual([]);
});
it("still honours the module gate for the owner", () => {
expect(
visibleCaptureKinds({ canAdd: true, canManage: true }, false, [...ALL]),
).toEqual(["measurement", "medication", "mood"]);
});
});
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -29,6 +30,14 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
*/
export function CustomMetricList() {
const { t } = useTranslations();
// v1.36.x — the section is dropped whole inside somebody else's record.
// Defining a metric is not a delegated verb, and every row here links to
// `/custom-metrics/{id}`, which sharing does not cover: the shell turns a
// delegate away at that route, so the rows would be doors into a wall.
// Logging a value against a custom metric IS admitted, but there is no
// reachable surface for it under a switch, and inventing one is a read-model
// change this does not make.
const { canManage } = useRecordCapabilities();
const router = useRouter();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
@@ -47,6 +56,8 @@ export function CustomMetricList() {
router.push(`/custom-metrics/${saved.id}`);
}
if (!canManage) return null;
return (
<section
className="border-border space-y-3 border-t pt-6"
+48 -29
View File
@@ -70,7 +70,12 @@ export interface CycleCalendarProps {
* renders as the distinct light oval instead of the predicted dot.
*/
confirmedOvulation?: string | null;
onSelectDay: (date: string) => void;
/**
* Open the day. Omitted inside somebody else's record: no grant level
* admits a cycle write, so the cells stay readable and stop being controls
* rather than opening a sheet whose every button the server would refuse.
*/
onSelectDay?: (date: string) => void;
className?: string;
}
@@ -208,34 +213,8 @@ export function CycleCalendar({
? "UNGRADED"
: undefined;
return (
<button
key={date}
type="button"
role="gridcell"
aria-label={aria}
aria-current={isToday ? "date" : undefined}
data-flow-level={flowLevel}
data-fertile={info?.isFertileWindow ? "true" : undefined}
data-predicted={
info?.isPredictedPeriod && !info?.isPeriodLogged
? "true"
: undefined
}
data-ovulation={
isConfirmedOvulation
? "confirmed"
: isPredictedOvulationDot
? "predicted"
: undefined
}
onClick={() => onSelectDay(date)}
className={cn(
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
isToday && "font-semibold",
)}
>
const content = (
<>
{/* Fertile window: a soft full-cell band (calm fill, not a hard
ring) so the window reads as a continuous range across days. */}
{info?.isFertileWindow ? (
@@ -311,7 +290,47 @@ export function CycleCalendar({
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
) : null}
</span>
</>
);
const cellClass = cn(
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
isToday && "font-semibold",
);
const cellAttrs = {
role: "gridcell" as const,
"aria-label": aria,
"aria-current": isToday ? ("date" as const) : undefined,
"data-flow-level": flowLevel,
"data-fertile": info?.isFertileWindow ? "true" : undefined,
"data-predicted":
info?.isPredictedPeriod && !info?.isPeriodLogged
? "true"
: undefined,
"data-ovulation": isConfirmedOvulation
? "confirmed"
: isPredictedOvulationDot
? "predicted"
: undefined,
};
return onSelectDay ? (
<button
key={date}
type="button"
{...cellAttrs}
onClick={() => onSelectDay(date)}
className={cn(
cellClass,
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{content}
</button>
) : (
<div key={date} {...cellAttrs} className={cellClass}>
{content}
</div>
);
})}
</div>
+41 -27
View File
@@ -16,6 +16,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
@@ -92,6 +93,10 @@ type CycleTab = (typeof CYCLE_TABS)[number];
export function CycleView() {
const { t } = useTranslations();
// 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();
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
@@ -163,24 +168,28 @@ export function CycleView() {
description={t("cycle.subtitle")}
actions={
<>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("cycle.tabs.settings")}
title={t("cycle.tabs.settings")}
data-slot="cycle-settings-wrench"
onClick={() => setTab("settings")}
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
{canManage && (
<>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("cycle.tabs.settings")}
title={t("cycle.tabs.settings")}
data-slot="cycle-settings-wrench"
onClick={() => setTab("settings")}
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
</>
)}
</>
}
/>
@@ -260,7 +269,10 @@ export function CycleView() {
) : null}
{/* Log CTA — first period when no cycle exists yet, next period
when the count paused on an overdue cycle. */}
{!loading && !calendarError && verdict?.dayOfCycle == null ? (
{canManage &&
!loading &&
!calendarError &&
verdict?.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
@@ -315,12 +327,14 @@ export function CycleView() {
>
{t("cycle.tabs.insights")}
</TabsTrigger>
<TabsTrigger
value="settings"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.settings")}
</TabsTrigger>
{canManage && (
<TabsTrigger
value="settings"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.settings")}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="calendar" className="mt-4 space-y-4">
@@ -359,7 +373,7 @@ export function CycleView() {
null)
: null
}
onSelectDay={openSheet}
onSelectDay={canManage ? openSheet : undefined}
/>
</>
)}
@@ -429,7 +443,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 ? (
) : profileQuery.data && canManage ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
@@ -0,0 +1,139 @@
/**
* The caregiver's front door, rendered.
*
* SSR-only, like the rest of the component suite: what is proven is the
* paint — who the card lists and when it exists at all. The tap that switches
* records is the switcher's existing mutation and is driven end to end in
* `e2e/account-sharing.spec.ts`.
*
* Mutation checks, run:
* - drop the `accounts.length === 0` bail → "an account nobody shares with
* sees nothing" goes red.
* - drop the `active != null` bail → "no doorway into the room you are in"
* goes red.
* - render the level from `entry.access` instead of `entry.canWrite` → "the
* level is the server's resolved boolean" goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
const GRANDMOTHER = {
accountId: "acct-gran",
username: "gran",
displayName: "Margarethe",
access: "read" as const,
canWrite: false,
};
const FATHER = {
accountId: "acct-dad",
username: "dad",
displayName: null,
access: "write" as const,
canWrite: true,
};
const mockAccessRef: { value: AccountAccess } = {
value: { accounts: [], active: null, canSwitch: false },
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "delegate",
username: "delegate",
email: null,
role: "USER",
avatarUrl: null,
modules: {},
accountAccess: mockAccessRef.value,
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-account-switch", () => ({
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
}));
import { LookingAfterCard } from "../looking-after-card";
function render(access: AccountAccess): string {
mockAccessRef.value = access;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<LookingAfterCard />
</I18nProvider>,
);
}
describe("<LookingAfterCard>", () => {
it("an account nobody shares with sees nothing", () => {
// The v1.36.0 posture: the feature is invisible until it applies to you.
// Not an empty state, not a prompt to ask somebody for access.
expect(render({ accounts: [], active: null, canSwitch: false })).toBe("");
});
it("lists every record shared with this account", () => {
const html = render({
accounts: [GRANDMOTHER, FATHER],
active: null,
canSwitch: true,
});
expect(html).toContain('data-slot="looking-after-card"');
expect(html).toContain('data-account-id="acct-gran"');
expect(html).toContain('data-account-id="acct-dad"');
// The display name where there is one, the username where there is not.
expect(html).toContain("Margarethe");
expect(html).toContain("dad");
});
it("the level is the server's resolved boolean", () => {
const html = render({
accounts: [GRANDMOTHER, FATHER],
active: null,
canSwitch: true,
});
expect(html).toContain("Can view and add");
expect(html).toContain("Can view");
});
it("a grant labelled write but resolved read reads as read", () => {
// `access` describes the row; `canWrite` is the decision the server made
// about it this second. A grant that lapsed, or a level this build does
// not honour, must not promise an add the next request would refuse.
const html = render({
accounts: [{ ...FATHER, access: "write", canWrite: false }],
active: null,
canSwitch: true,
});
expect(html).toContain("Can view");
expect(html).not.toContain("Can view and add");
});
it("shows no health data, only a doorway", () => {
const html = render({
accounts: [GRANDMOTHER],
active: null,
canSwitch: true,
});
// A preview would put somebody else's readings on this dashboard, outside
// the banner frame that says which record you are looking at. The card
// carries names and levels and nothing else.
expect(html).not.toMatch(/\d+(\.\d+)?\s*(kg|mmHg|bpm)/);
});
it("no doorway into the room you are already in", () => {
expect(
render({
accounts: [GRANDMOTHER],
active: GRANDMOTHER,
canSwitch: true,
}),
).toBe("");
});
});
+64 -52
View File
@@ -25,6 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
@@ -37,6 +38,11 @@ export function DashboardHeader({
const { user } = useAuth();
const mounted = useMounted();
const nutrientsEnabled = useModuleEnabled("nutrients");
// v1.36.x — the dashboard quick-add offers four kinds; a delegation admits
// two of them (a reading, a dose). Mood and water stay 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();
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -87,24 +93,27 @@ 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+. */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
data-testid="dashboard-customize-shortcut"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
<Link
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
across viewports. The v1.4.27 fix pinned a `size="sm"
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
contract on mobile, but `size="sm"` is h-8 (32 px) and
@@ -116,53 +125,56 @@ export function DashboardHeader({
44 px tall under finger pressure and shrinks back to
the desktop-friendly 36 px on `sm:` upwards. The icon
+ label keep the same visual contract. */}
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
<Button
size="default"
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-w-[calc(100vw-2rem)]"
>
{/* Menu items must each carry a self-contained verb-phrase
{/* Menu items must each carry a self-contained verb-phrase
("Log measurement", "Log mood") — the trigger above already
says "Add", and the icon is `aria-hidden`, so the visible
text is the only thing distinguishing the rows. v1.4.15
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>
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
{/* v1.4.37 W7b — third quick-add row: medication intake.
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
{canManage && (
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
</DropdownMenuItem>
)}
{/* v1.4.37 W7b — third quick-add row: medication intake.
Same Sheet-on-mobile / Dialog-on-desktop primitive as
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>
{nutrientsEnabled && (
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWater")}
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{nutrientsEnabled && canManage && (
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddWater")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
}
/>
@@ -0,0 +1,129 @@
"use client";
import { HeartHandshake, Loader2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
accountLabel,
type AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
/**
* v1.36.x — the caregiver's front door.
*
* ## Why this is on the dashboard and not in settings
*
* Sharing shipped as a settings panel and a menu three levels deep: user menu
* → switcher → the record. That is the right place to MANAGE a grant, and the
* wrong place to use one. Somebody who opens this app every morning to check
* on a parent does not go to settings to do it, and UI-STANDARDS §10 draws
* exactly that line: settings are account utilities, not feature
* destinations. Looking after somebody is a feature destination.
*
* ## What it is not
*
* It is a doorway, not a preview. No numbers, no last reading, no status.
* Putting a slice of somebody else's health record on a delegate's own
* dashboard would place it outside the one frame the whole design leans on —
* the banner that says which record you are inside. Everything about the
* other person is read after the switch, under that banner, or not at all.
*
* ## Where the content comes from
*
* `accountAccess.accounts`, the same resolved list the switcher renders, and
* the switch is the same mutation the switcher fires. No second mechanism, no
* second source of truth about who may open what. An account nobody has
* shared with sees nothing at all — no empty state, no invitation to ask
* somebody for access (the v1.36.0 posture: the feature is invisible until it
* applies to you).
*
* Design: `Card` + `TileHeader` per UI-STANDARDS §1 and §5 (icon and title in
* the foreground colour, title `text-base`), the person's name as content in
* `text-foreground` and the grant level as meta in `text-muted-foreground`
* per §3, and the §11 row shape — label block `min-w-0 flex-1`, the whole row
* a single tap target at the `min-h-11` mobile floor.
*/
export function LookingAfterCard() {
const { t } = useTranslations();
const { user } = useAuth();
const switchAccount = useAccountSwitch();
const access = user?.accountAccess;
const accounts = access?.accounts ?? [];
// Inside somebody else's record the card would be a doorway to the room you
// are already in; the banner owns that context and carries the way out.
if (access?.active != null) return null;
if (accounts.length === 0) return null;
return (
<Card data-slot="looking-after-card">
<CardContent className="space-y-3">
<TileHeader
icon={HeartHandshake}
title={t("recordSharing.lookingAfter.title")}
titleAs="h2"
/>
<p className="text-muted-foreground text-sm">
{t("recordSharing.lookingAfter.description")}
</p>
<ul className="divide-y">
{accounts.map((entry) => (
<li key={entry.accountId}>
<button
type="button"
data-slot="looking-after-row"
data-account-id={entry.accountId}
disabled={switchAccount.isPending}
onClick={() => switchAccount.mutate(entry.accountId)}
aria-label={t("recordSharing.lookingAfter.open", {
name: accountLabel(entry),
})}
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate text-sm font-medium">
{accountLabel(entry)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{levelLabel(entry, t)}
</span>
</span>
{switchAccount.isPending && (
<Loader2
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
)}
</button>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
/**
* The grant level, in the words the person reading it would use.
*
* Bound from the server's resolved `canWrite`, never from the `access` label
* beside it: the label describes the row, the boolean is the decision, and a
* lapsed WRITE grant must read as view-only here exactly as it behaves.
*
* No "last opened" line: `lastUsedAt` is not on this payload, and a relative
* time invented from something else would be a placeholder wearing a fact's
* clothes.
*/
function levelLabel(
entry: AccountAccessEntry,
t: (key: string) => string,
): string {
return entry.canWrite
? t("recordSharing.lookingAfter.levelWrite")
: t("recordSharing.lookingAfter.levelRead");
}
@@ -15,6 +15,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
/**
* Shared single-row delete button + confirm dialog for the data-
@@ -25,6 +26,15 @@ import { useTranslations } from "@/lib/i18n/context";
* so the title/description are passed in rather than reaching for a fixed
* translation key. `cancelLabel` / `confirmLabel` default to the shared
* `common.*` strings.
*
* 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.
*/
export function DeleteButton({
onConfirm,
@@ -47,6 +57,8 @@ export function DeleteButton({
iconClassName?: string;
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
if (!canManage) return null;
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -15,6 +15,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
/**
* Selection action bar for the data-management lists (measurements +
@@ -29,6 +30,11 @@ import { useTranslations } from "@/lib/i18n/context";
*
* 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.
*/
export function SelectionActionBar({
count,
@@ -46,8 +52,9 @@ export function SelectionActionBar({
confirmBody: string;
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
if (count <= 0) return null;
if (count <= 0 || !canManage) return null;
return (
<div
+23 -20
View File
@@ -55,7 +55,8 @@ export function DocumentCard({
document: InboundDocumentDto;
selected: boolean;
/** `range` = extend the selection from the last anchor (shift-click). */
onToggleSelected: (id: string, range?: boolean) => void;
/** Omitted inside somebody else's record: selection feeds bulk edits. */
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — undo-able delete owned by the page. */
onDelete?: (id: string) => void;
@@ -173,23 +174,25 @@ export function DocumentCard({
</p>
) : null}
</div>
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
)}
/>
{onToggleSelected && (
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
)}
/>
)}
</div>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ||
@@ -266,7 +269,7 @@ export function DocumentCard({
const action = documentCardKeyAction(e.key);
if (action === "select") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
onToggleSelected?.(document.id, e.shiftKey);
} else if (action === "delete") {
e.preventDefault();
onDelete?.(document.id);
@@ -277,7 +280,7 @@ export function DocumentCard({
cancelLongPress();
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
onToggleSelected(document.id);
onToggleSelected?.(document.id);
}, LONG_PRESS_MS);
}}
onTouchMove={cancelLongPress}
@@ -16,6 +16,7 @@
* copy. A Share action in the footer opens the clinician share-link create
* flow (shared `ShareLinkCreateForm`) with this document pre-attached.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -246,6 +247,7 @@ export function DocumentDetailSheet({
(fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
).length ?? 0;
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
const labsModuleEnabled = user?.modules?.labs !== false;
const episodes = useIllnessEpisodes(true);
@@ -513,15 +515,23 @@ export function DocumentDetailSheet({
// labels collapse to icon-only (aria-labelled) so three controls
// never overflow the sheet at 360 px; the labels return at `sm+`.
<div className="flex w-full items-center justify-between gap-2">
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
{/* v1.36.x — deleting, sharing and re-filing a document are the
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 ? (
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{aiEnabled ? (
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
@@ -546,17 +556,19 @@ export function DocumentDetailSheet({
</span>
</Button>
) : null}
<Button
variant="outline"
onClick={() => setShareOpen(true)}
data-slot="document-share"
aria-label={t("documents.detail.share")}
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.share")}
</span>
</Button>
{canManage && (
<Button
variant="outline"
onClick={() => setShareOpen(true)}
data-slot="document-share"
aria-label={t("documents.detail.share")}
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.share")}
</span>
</Button>
)}
<Button
variant="outline"
asChild
@@ -620,6 +632,10 @@ export function DocumentDetailSheet({
<DocumentSummaryBlock
summary={doc.summary}
// 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}
summaryState={doc.summaryState}
generatedAtLabel={
doc.summaryGeneratedAt
@@ -632,41 +648,43 @@ export function DocumentDetailSheet({
onGenerate={generateStoredSummary}
/>
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>
{canManage && (
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>
)}
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
@@ -693,6 +711,16 @@ export function DocumentDetailSheet({
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : !canManage ? (
<p
id="document-title-input"
className={cn(
"min-h-10 px-3 py-2 text-sm",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</p>
) : (
<button
type="button"
@@ -724,8 +752,12 @@ export function DocumentDetailSheet({
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
{/* Read-only rather than absent: the value IS the content
a delegate came to read, and a form field that shows it
without accepting a change is the honest rendering. */}
<Select
value={doc.kind}
disabled={!canManage}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
@@ -748,6 +780,7 @@ export function DocumentDetailSheet({
</Label>
<DateField
id="document-date-field"
disabled={!canManage}
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({
@@ -778,7 +811,7 @@ export function DocumentDetailSheet({
{t("documents.detail.noConditions")}
</span>
) : null}
{(episodes.data?.length ?? 0) > 0 ? (
{canManage && (episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
@@ -47,6 +47,7 @@ export function DocumentSummaryBlock({
aiEnabled,
isGenerating,
actionsDisabled,
canGenerate = true,
onGenerate,
}: {
/** The stored, decrypted summary, or null when there is none to show. */
@@ -59,6 +60,12 @@ export function DocumentSummaryBlock({
isGenerating: boolean;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
/**
* v1.36.x — false inside somebody else's record: a stored summary is
* readable, generating one writes to the owner's record and no grant level
* admits it. The state line stays; the button goes.
*/
canGenerate?: boolean;
onGenerate: () => void;
}) {
const { t } = useTranslations();
@@ -103,7 +110,7 @@ export function DocumentSummaryBlock({
<p className="text-muted-foreground text-xs">
{t(absentCopyKey(summaryState))}
</p>
{aiEnabled ? (
{aiEnabled && canGenerate ? (
<Button
type="button"
variant="outline"
@@ -63,7 +63,8 @@ export function DocumentTimeline({
isFetchingNextPage: boolean;
onLoadMore: () => void;
selectedIds: ReadonlySet<string>;
onToggleSelected: (id: string, range?: boolean) => void;
/** Omitted inside somebody else's record: selection feeds bulk edits. */
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — the page owns the undo-able delete. */
onDelete?: (id: string) => void;
+30 -17
View File
@@ -14,6 +14,7 @@
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
* the gate server-side — this is a UX redirect, not the security boundary.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
useInfiniteQuery,
useMutation,
@@ -179,6 +180,11 @@ export function closeDocumentSelectionAfterCoachHandoff(
export function DocumentsView() {
const { t } = useTranslations();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
// 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 } = useRecordCapabilities();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -369,7 +375,7 @@ export function DocumentsView() {
},
[episodeIdFilter, t],
);
const { dropActive } = usePageFileDrop(enqueueFiles);
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
const episodes = useIllnessEpisodes(true);
@@ -381,6 +387,7 @@ export function DocumentsView() {
const contentIndex = usage.data?.contentIndex;
const canIndexContent = contentIndex?.enabled ?? false;
const showIndexAll =
canManage &&
canIndexContent &&
contentIndex !== undefined &&
contentIndex.totalCount > 0 &&
@@ -749,18 +756,22 @@ export function DocumentsView() {
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
canManage ? (
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
) : null
}
/>
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={enqueueFiles}
/>
{canManage && (
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={enqueueFiles}
/>
)}
<DocumentFilterBar
searchValue={searchDraft}
@@ -802,10 +813,12 @@ export function DocumentsView() {
description={t("documents.empty.description")}
ctaSize="lg"
action={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
canManage ? (
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
) : undefined
}
/>
) : showEmpty && isFiltered ? (
@@ -828,9 +841,9 @@ export function DocumentsView() {
isFetchingNextPage={list.isFetchingNextPage}
onLoadMore={() => void list.fetchNextPage()}
selectedIds={selectedIds}
onToggleSelected={toggleSelected}
onToggleSelected={canManage ? toggleSelected : undefined}
onOpen={openDetail}
onDelete={(id) => deleteBulk([id])}
onDelete={canManage ? (id) => deleteBulk([id]) : undefined}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
@@ -844,7 +857,7 @@ export function DocumentsView() {
contentIndexEnabled={usage.data?.contentIndex.enabled}
/>
{selectedIds.size > 0 ? (
{canManage && selectedIds.size > 0 ? (
<DocumentBulkBar
selectedCount={selectedIds.size}
episodes={(episodes.data ?? []).map((e) => ({
+12 -4
View File
@@ -22,18 +22,26 @@
*/
import { useEffect, useRef, useState } from "react";
export function usePageFileDrop(onFiles: (files: File[]) => void): {
/**
* @param onFiles Where dropped or pasted files go. Omitted inside somebody
* else's record: uploading is not a delegated verb, so the listeners are
* never attached and the drop overlay never appears. Offering the overlay
* and swallowing the file would be worse than the browser's own behaviour.
*/
export function usePageFileDrop(onFiles?: (files: File[]) => void): {
dropActive: boolean;
} {
const [dropActive, setDropActive] = useState(false);
const depthRef = useRef(0);
const onFilesRef = useRef(onFiles);
const enabled = onFiles != null;
useEffect(() => {
onFilesRef.current = onFiles;
}, [onFiles]);
useEffect(() => {
if (!enabled) return;
const isFileDrag = (event: DragEvent) =>
event.dataTransfer?.types?.includes("Files") ?? false;
@@ -59,7 +67,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
depthRef.current = 0;
setDropActive(false);
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) onFilesRef.current(files);
if (files.length > 0) onFilesRef.current?.(files);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || depthRef.current === 0) return;
@@ -70,7 +78,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0) return;
event.preventDefault();
onFilesRef.current(files);
onFilesRef.current?.(files);
};
window.addEventListener("dragenter", onDragEnter);
@@ -87,7 +95,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("paste", onPaste);
};
}, []);
}, [enabled]);
return { dropActive };
}
@@ -12,6 +12,7 @@
* day-log history, paginated server-side); when nothing has been logged it
* renders a calm prompt to log a day.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -91,6 +92,8 @@ export function IllnessDayTimeline({
onLogDay: () => void;
}) {
const { t } = useTranslations();
// v1.36.x — logging a day is not a delegated verb.
const { canManage } = useRecordCapabilities();
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
const dayLogs = data?.dayLogs ?? [];
@@ -117,13 +120,15 @@ export function IllnessDayTimeline({
<p className="text-muted-foreground text-sm">
{t("illness.timeline.empty")}
</p>
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={onLogDay}
>
{t("illness.logDay")}
</Button>
{canManage && (
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={onLogDay}
>
{t("illness.logDay")}
</Button>
)}
</div>
) : (
<div className="space-y-4">
@@ -8,6 +8,7 @@
* red-flag / pre-onset / nadir). Calm + retrospective — no prediction UI, no
* alarming colour.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
@@ -36,6 +37,9 @@ function todayLocal(): string {
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 fmt = useFormatters();
const {
data: episode,
@@ -93,26 +97,28 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</div>
)}
</div>
{episode?.note ? (
+78 -60
View File
@@ -15,6 +15,7 @@
* per-day timeline and correlation card live. Retrospective-only copy — a
* journal, not a medical device, and it does not diagnose.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
@@ -73,6 +74,7 @@ function EpisodeCard({
view,
}: EpisodeCardProps) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const fmt = useFormatters();
const active = episode.resolvedAt === null;
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
@@ -107,24 +109,26 @@ function EpisodeCard({
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</div>
)}
</CardContent>
</Card>
);
@@ -141,16 +145,18 @@ function EpisodeCard({
<span className="block truncate">{episode.label}</span>
</Link>
</CardTitle>
<CardAction>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</CardAction>
{canManage && (
<CardAction>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</CardAction>
)}
</CardHeader>
<CardContent className="flex h-full flex-col space-y-3">
@@ -205,14 +211,16 @@ 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. */}
<div className="mt-auto pt-0">
<Button
className="min-h-11 w-full sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
</div>
{canManage && (
<div className="mt-auto pt-0">
<Button
className="min-h-11 w-full sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
</div>
)}
</CardContent>
</Card>
);
@@ -308,6 +316,10 @@ function EpisodeGroup({
export function IllnessView() {
const { t } = useTranslations();
// 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 {
data: episodes,
isLoading,
@@ -380,29 +392,33 @@ export function IllnessView() {
<>
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Link
href="/settings/layout/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
{canAdd && (
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</>
}
/>
@@ -458,10 +474,12 @@ export function IllnessView() {
description={t("illness.empty.body")}
ctaSize="lg"
action={
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
canAdd ? (
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
) : undefined
}
/>
)}
+36 -26
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -93,6 +94,7 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { canAdd, canManage } = useRecordCapabilities();
const { t, locale } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
@@ -346,17 +348,19 @@ 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"
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
control. The full reading feed lives on
`/labs/[biomarkerId]/values`; the detail page keeps the
@@ -378,18 +382,22 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</Link>
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
{/* v1.36.x — entering a result is an admitted delegated write;
renaming or removing the marker is not. */}
{canAdd && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
)}
</div>
</div>
@@ -429,9 +437,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
canAdd ? (
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
) : undefined
}
/>
</CardContent>
+12 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -95,6 +96,7 @@ export function LabForm({
footerSlot,
}: LabFormProps) {
const { t } = useTranslations();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const formId = useId();
@@ -199,7 +201,16 @@ export function LabForm({
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
});
toast.success(t("labs.form.savedToast"));
toast.success(
t("labs.form.savedToast"),
recordName
? {
description: t("recordSharing.toast.savedTo", {
name: recordName,
}),
}
: undefined,
);
if (keepOpen) {
// A real lab report shares one blood-draw date across every
// analyte — clear the reading but deliberately KEEP `takenAt` so
+15 -11
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
@@ -74,6 +75,7 @@ function parseDecimal(raw: string): number | null {
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
// Client-side column sort over the in-memory reading feed.
@@ -361,17 +363,19 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
+3 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
@@ -77,6 +78,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canAdd } = useRecordCapabilities();
const { prefs } = useModuleListPrefs("labs");
// v1.22 — a short, factual line under each marker heading describing what the
@@ -167,7 +169,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst ? (
onAddFirst && canAdd ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
@@ -11,6 +11,19 @@ const captureState = vi.hoisted(() => ({
nutrientsEnabled: true,
}));
// v1.36.x — the picker asks what the record allows before it offers a kind.
// These fixtures are the caller's own record, which is every kind; the
// delegation cases are covered in
// `src/components/__tests__/delegated-write-affordances.test.tsx`.
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
}),
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof ReactModule>();
return {
@@ -10,6 +10,19 @@ vi.mock("@/hooks/use-module-enabled", () => ({
moduleKey === "nutrients" ? moduleGate.nutrientsEnabled : true,
}));
// v1.36.x — the picker asks what the record allows before it offers a kind.
// These fixtures are the caller's own record, which is every kind; the
// delegation cases are covered in
// `src/components/__tests__/delegated-write-affordances.test.tsx`.
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
}),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
+20 -11
View File
@@ -21,6 +21,7 @@ import type { ModuleKey } from "@/lib/modules/registry";
import { useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
Sheet,
SheetContent,
@@ -97,6 +98,8 @@ export function BottomNav() {
// the same resolved payload, so the two bars cannot disagree about what a
// delegate is offered.
const sharedRecord = user?.accountAccess?.active != null;
const { canAdd, canManage } = useRecordCapabilities();
const canCapture = canAdd || canManage;
// 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
@@ -191,18 +194,24 @@ export function BottomNav() {
collar (a `bg-card` ring that punches it out of the bar) plus
a stronger shadow so it reads as the bar's primary CTA rather
than a fifth flush tab. */}
{/* v1.36.x — a read-only delegate has nothing to capture: every
surface behind the picker is refused for them, so the bar's
primary CTA goes rather than opening onto an empty sheet. At
WRITE the picker itself drops the kinds the grant excludes. */}
<div className="flex flex-1 items-center justify-center">
<button
type="button"
aria-label={t("nav.capture.title")}
aria-haspopup="dialog"
aria-expanded={captureOpen}
onClick={() => setCaptureOpen(true)}
data-testid="bottom-nav-capture"
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Plus className="h-7 w-7" />
</button>
{canCapture && (
<button
type="button"
aria-label={t("nav.capture.title")}
aria-haspopup="dialog"
aria-expanded={captureOpen}
onClick={() => setCaptureOpen(true)}
data-testid="bottom-nav-capture"
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/70 ring-card -mt-5 flex h-14 w-14 items-center justify-center rounded-full shadow-lg ring-4 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Plus className="h-7 w-7" />
</button>
)}
</div>
{primaryRight.map(renderPrimary)}
+70 -22
View File
@@ -21,6 +21,10 @@ import {
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import {
useRecordCapabilities,
type RecordCapabilities,
} from "@/hooks/use-record-capabilities";
/**
* The center "Log" capture action (iOS parity — the bottom bar's
@@ -48,6 +52,45 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
type CaptureKind = "measurement" | "medication" | "mood" | "water";
/**
* v1.36.x — which of the four capture surfaces a delegation admits.
*
* A WRITE grant covers entering a reading and marking a dose. It does not
* cover a mood entry or a glass of water: those verbs stay on the owner's own
* authentication and the server refuses them under a switch. The picker is the
* fastest path into all four 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",
]);
/** The order the chooser lists them in. */
const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"measurement",
"medication",
"mood",
"water",
];
/**
* The capture kinds to offer, given what the record allows and whether the
* nutrients module is on. Exported pure so the delegation rule can be pinned
* without opening a sheet: an SSR test cannot tap the button that opens it.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
nutrientsEnabled: boolean,
kinds: ReadonlyArray<CaptureKind>,
): CaptureKind[] {
return kinds.filter((kind) => {
if (kind === "water" && !nutrientsEnabled) return false;
if (caps.canManage) return true;
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
});
}
interface CapturePickerProps {
/** Whether the picker chooser sheet is open. */
open: boolean;
@@ -58,6 +101,12 @@ interface CapturePickerProps {
export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const { t } = useTranslations();
const nutrientsEnabled = useModuleEnabled("nutrients");
const capabilities = useRecordCapabilities();
const offered = visibleCaptureKinds(
capabilities,
nutrientsEnabled,
CAPTURE_KIND_ORDER,
);
const [kind, setKind] = useState<CaptureKind | null>(null);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
@@ -66,7 +115,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function chooseKind(next: CaptureKind) {
if (next === "water" && !nutrientsEnabled) return;
if (!offered.includes(next)) return;
// Close the chooser first, then open the form sheet so only one
// bottom-sheet is mounted at a time (no stacked backdrops).
onOpenChange(false);
@@ -89,7 +138,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
closeForm();
}
const options: ReadonlyArray<{
const allOptions: ReadonlyArray<{
kind: CaptureKind;
label: string;
description: string;
@@ -120,6 +169,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
icon: GlassWater,
},
];
const options = allOptions.filter((opt) => offered.includes(opt.kind));
const formTitleByKind: Record<CaptureKind, string> = {
measurement: t("measurements.addMeasurement"),
@@ -145,27 +195,25 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
className="grid grid-cols-1 gap-2"
data-testid="capture-picker-options"
>
{options.map((opt) =>
opt.kind !== "water" || nutrientsEnabled ? (
<button
key={opt.kind}
type="button"
data-testid={`capture-picker-${opt.kind}`}
onClick={() => chooseKind(opt.kind)}
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<opt.icon className="h-5 w-5" aria-hidden="true" />
{options.map((opt) => (
<button
key={opt.kind}
type="button"
data-testid={`capture-picker-${opt.kind}`}
onClick={() => chooseKind(opt.kind)}
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<opt.icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{opt.label}</span>
<span className="text-muted-foreground block text-xs">
{opt.description}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{opt.label}</span>
<span className="text-muted-foreground block text-xs">
{opt.description}
</span>
</span>
</button>
) : null,
)}
</span>
</button>
))}
</div>
</ResponsiveSheet>
@@ -28,6 +28,7 @@
* hosting the per-reminder enable/disable toggles (moved off the
* card so the card carries a single med-style kebab).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -219,6 +220,7 @@ export function VorsorgeSection({
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const router = useRouter();
const {
data: reminders,
@@ -359,7 +361,11 @@ export function VorsorgeSection({
// v1.18.6 (MOD-02) — the primary add affordance reads "hinzufügen" like
// every other module's add button.
const addButton = (
// v1.36.x — a checkup reminder belongs to the person whose schedule it is,
// 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 ? (
<Button
type="button"
className="min-h-11 shrink-0 sm:min-h-9"
@@ -368,13 +374,13 @@ export function VorsorgeSection({
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
);
) : null;
// 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 = (
const wrenchButton = canManage ? (
<Button
asChild
variant="ghost"
@@ -389,7 +395,7 @@ export function VorsorgeSection({
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
);
) : null;
return (
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
@@ -709,9 +715,11 @@ export function VorsorgeSection({
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
<Button type="button" onClick={openCreate}>
{t("common.add")}
</Button>
canManage ? (
<Button type="button" onClick={openCreate}>
{t("common.add")}
</Button>
) : undefined
}
/>
)}
@@ -787,6 +795,7 @@ function VorsorgeCard({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { canManage } = useRecordCapabilities();
// 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
@@ -855,7 +864,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 = (
const headerActions = canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -898,14 +907,14 @@ function VorsorgeCard({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
) : null;
// The measure / mark-done action. One constant appearance in every state —
// no due-driven tint, no label swap (v1.27.5 retired the former green
// "do it now" wash; a button that changes colour by schedule state reads as
// a different control). Due-ness is communicated by the discreet coloured
// next-due text above.
const primaryButton = (
const primaryButton = canManage ? (
<Button
type="button"
className="min-h-11 w-full"
@@ -928,7 +937,7 @@ function VorsorgeCard({
</>
)}
</Button>
);
) : null;
const deleteDialog = (
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
@@ -1114,7 +1123,9 @@ function VorsorgeCard({
)}
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
<div className="mt-auto pt-0">{primaryButton}</div>
{primaryButton ? (
<div className="mt-auto pt-0">{primaryButton}</div>
) : null}
</CardContent>
</Card>
@@ -1,5 +1,6 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
@@ -238,6 +239,7 @@ export function MeasurementForm({
footerSlot,
}: MeasurementFormProps) {
const { t } = useTranslations();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const unitDisplay = useUnitDisplay();
@@ -371,7 +373,19 @@ export function MeasurementForm({
setNotes("");
await invalidateKeys(queryClient, measurementDependentKeys);
await refetchInactiveDailyReads(queryClient);
toast.success(t("common.saved"));
// v1.36.x — a delegate's receipt names the record. "Saved" alone is
// the one confirmation somebody acting for another person does not
// need, and the reading has just left their own history for good.
toast.success(
t("common.saved"),
recordName
? {
description: t("recordSharing.toast.savedTo", {
name: recordName,
}),
}
: undefined,
);
onSuccess?.();
} catch (err) {
setError(localizedApiError(err, t, "measurements.saveError"));
+108 -65
View File
@@ -1,8 +1,10 @@
"use client";
import { accountLabel } from "@/lib/sharing/account-access-view";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -283,7 +285,20 @@ export function MeasurementList({
}: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
// 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 } =
useRecordCapabilities();
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
// was offered no add button to be surprised by, there is nothing to explain.
const delegateName =
inSharedRecord && canWrite && activeRecord
? accountLabel(activeRecord)
: null;
const unitDisplay = useUnitDisplay();
// v1.32.26 — a single-reading row renders its value + unit in the user's
// preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the
@@ -928,38 +943,58 @@ export function MeasurementList({
// an outage never reads as "you have no measurements".
<QueryErrorCard onRetry={() => void refetch()} />
) : !data?.measurements?.length ? (
// v1.4.15 phase-C5: replaces the bare-text empty rectangle.
// Filter-aware copy distinguishes brand-new accounts ("no
// measurements yet") from filtered-out lists ("no measurements
// match this filter") and exposes the right CTA for each
// case.
<EmptyState
icon={<Activity className="size-6" />}
title={
typeFilter === "ALL" || lockedType
? t("measurements.emptyTitle")
: t("measurements.emptyFilteredTitle")
}
description={
typeFilter === "ALL" || lockedType
? t("measurements.emptyDescription")
: t("measurements.emptyFilteredDescription")
}
action={
// v1.8.5 — a locked-type list has no "reset filter" path;
// the metric is fixed by the route.
typeFilter !== "ALL" && !lockedType ? (
<Button variant="outline" onClick={() => setTypeFilter("ALL")}>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst ? (
<Button onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
</Button>
) : undefined
}
/>
<>
{/* v1.4.15 phase-C5: replaces the bare-text empty rectangle.
Filter-aware copy distinguishes brand-new accounts ("no
measurements yet") from filtered-out lists ("no measurements
match this filter") and exposes the right CTA for each case. */}
<EmptyState
icon={<Activity className="size-6" />}
title={
typeFilter === "ALL" || lockedType
? t("measurements.emptyTitle")
: t("measurements.emptyFilteredTitle")
}
description={
typeFilter === "ALL" || lockedType
? t("measurements.emptyDescription")
: t("measurements.emptyFilteredDescription")
}
action={
// v1.8.5 — a locked-type list has no "reset filter" path;
// the metric is fixed by the route.
typeFilter !== "ALL" && !lockedType ? (
<Button
variant="outline"
onClick={() => setTypeFilter("ALL")}
>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst && canAdd ? (
<Button onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
</Button>
) : undefined
}
/>
{/* The one place in the app the asymmetry is spelled out. A
delegate who may add will look for the edit control beside the
row they just entered, so the list that carries the add button
says once, in one muted line, where the limit is. Every other
hidden control stays silent: the banner already names the mode,
and a chip beside each absent button would be noise. */}
{delegateName && (
<p
data-slot="delegate-add-only-note"
className="text-muted-foreground mx-auto max-w-prose text-center text-xs"
>
{t("recordSharing.delegate.addOnlyNote", {
name: delegateName,
})}
</p>
)}
</>
) : (
<>
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
@@ -978,19 +1013,23 @@ export function MeasurementList({
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-4">
{/* v1.15.13 — select-all-on-page header checkbox. */}
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
{/* v1.15.13 — select-all-on-page header checkbox.
v1.36.x — bulk delete is the owner's alone, so a
delegate gets no selection column. */}
{canManage && (
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
)}
</TableHead>
<TableHead className="w-28">
{t("measurements.type")}
@@ -1051,7 +1090,7 @@ export function MeasurementList({
<TableCell className="pl-4">
{/* Grouped/synthetic rows aren't individually
deletable, so they have no checkbox. */}
{!isGrouped && (
{!isGrouped && canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
@@ -1178,15 +1217,17 @@ export function MeasurementList({
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
<DeleteButton
onConfirm={() =>
deleteMutation.mutate(m.id)
@@ -1247,7 +1288,7 @@ export function MeasurementList({
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 — multi-select checkbox in a 44px tap
target; absent for synthetic grouped rows. */}
{!isGrouped && (
{!isGrouped && canManage && (
// v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
// (itself a `<button role=checkbox>`) inside a
// 44px wrapper `<button>` for WCAG 2.5.5 — but a
@@ -1375,14 +1416,16 @@ export function MeasurementList({
</Button>
) : (
<>
<Button
variant="ghost"
size="icon-lg"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon-lg"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
iconClassName="h-4 w-4"
onConfirm={() => deleteMutation.mutate(m.id)}
@@ -40,10 +40,16 @@ function makeClient(): QueryClient {
}
function render(node: React.ReactNode, client?: QueryClient) {
const tree = client ? (
<QueryClientProvider client={client}>{node}</QueryClientProvider>
) : (
node
// v1.36.x — every card part now asks what the record on screen allows
// before it renders a control, and that answer rides the account query.
// The parts that carry no query of their own were rendered here without a
// provider; they need one now. Fixtures are the caller's own record, which
// is every capability — the delegation cases live in
// `src/components/__tests__/delegated-write-affordances.test.tsx`.
const tree = (
<QueryClientProvider client={client ?? makeClient()}>
{node}
</QueryClientProvider>
);
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{tree}</I18nProvider>,
@@ -2,6 +2,7 @@ import { Check, Loader2, SkipForward } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
interface MedicationIntakeActionsProps {
/** "take" | "skip" while the matching request is in flight, else null. */
@@ -27,6 +28,11 @@ export function MedicationIntakeActions({
onRecordIntake,
}: MedicationIntakeActionsProps) {
const { t } = useTranslations();
// 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;
return (
<div className="flex gap-2">
@@ -31,6 +31,7 @@
* administration.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
useCallback,
useEffect,
@@ -171,6 +172,19 @@ const TAB_SLUGS = [
] as const;
type TabSlug = (typeof TAB_SLUGS)[number];
/**
* Tabs whose entire content is owner-only work: the schedule editor and its
* plan history, the supply ledger, the external-token row, and the advanced
* surface (wizard, import, pause, end, purge, delete). A delegation admits
* none of it at any level.
*/
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
"zeitplan",
"bestand",
"api",
"erweitert",
]);
/**
* 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
@@ -222,6 +236,7 @@ export function MedicationDetailTabs({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { canManage } = useRecordCapabilities();
const router = useRouter();
const searchParams = useSearchParams();
@@ -273,15 +288,23 @@ export function MedicationDetailTabs({
// reminds, so the Zeitplan tab (times editor + reminder card +
// plan-history) does not apply and is dropped from the registry; a
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
// v1.36.x — four tabs are nothing BUT owner work: the schedule editor, the
// supply ledger, the external-token row and the advanced surface that holds
// the wizard, the import and the destructive zone. None of it is delegated,
// so inside somebody else's record they leave the registry entirely rather
// than opening onto controls the server refuses — which also means a stale
// `?tab=erweitert` deep link lands on the landing tab, the same way an
// inapplicable tab already does.
const availableTabs = useMemo<TabSlug[]>(
() =>
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded) &&
(slug !== "wirkung" || hasEfficacyTarget),
(slug !== "wirkung" || hasEfficacyTarget) &&
(canManage || !OWNER_ONLY_TABS.has(slug)),
),
[isInjectable, asNeeded, hasEfficacyTarget],
[isInjectable, asNeeded, hasEfficacyTarget, canManage],
);
const requestedRaw = searchParams?.get("tab");
+125 -109
View File
@@ -39,6 +39,7 @@
* the ledger uses the semantic feedback vocabulary on the row, not the card.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -162,6 +163,10 @@ export function DoseHistoryLedger({
const fmt = useFormatters();
const dateFormatPref = useDateFormatPreference();
const { user } = useAuth();
// 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 queryClient = useQueryClient();
const timeZone = user?.timezone || "Europe/Berlin";
@@ -413,16 +418,18 @@ export function DoseHistoryLedger({
{t("medications.detail.verlauf.summaryEmpty")}
</p>
)}
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="ledger-add"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.verlauf.add")}
</Button>
{canAdd && (
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="ledger-add"
>
<Plus aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.verlauf.add")}
</Button>
)}
</div>
{isLoading && (
@@ -571,6 +578,7 @@ function LedgerRowItem({
onUnpin: (eventId: string) => void;
}) {
const { t } = useTranslations();
const { canAdd, canManage } = useRecordCapabilities();
const fmt = useFormatters();
const actionable = isSlotActionable(row);
@@ -676,7 +684,7 @@ function LedgerRowItem({
</div>
<div className="flex shrink-0 items-center gap-1">
{showTakenButton && (
{showTakenButton && canAdd && (
<Button
variant="outline"
size="sm"
@@ -697,118 +705,126 @@ function LedgerRowItem({
{t("medications.detail.verlauf.markTaken")}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
time: timeLabel,
})}
data-slot="ledger-row-menu"
>
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" data-slot="ledger-row-menu-content">
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() => onMark(row, "taken")}
data-slot="ledger-menu-taken"
>
<Check aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.markTaken")}
</DropdownMenuItem>
)}
{actionable && (
<DropdownMenuItem
onClick={() => onMark(row, "skipped")}
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{t("medications.detail.verlauf.markSkipped")}
</DropdownMenuItem>
)}
{canPin && (
<DropdownMenuItem
onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.action", {
time: fmt.time(new Date(dueContext!.at)),
{canManage && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
time: timeLabel,
})}
</DropdownMenuItem>
)}
{canUnpin && (
<DropdownMenuItem
onClick={() => onUnpin(intake!.id as string)}
data-slot="ledger-unpin"
data-slot="ledger-row-menu"
>
<Unlink aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.release")}
</DropdownMenuItem>
)}
{intake?.id && (
<>
<MoreVertical aria-hidden="true" className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
data-slot="ledger-row-menu-content"
>
{actionable && !showTakenButton && (
<DropdownMenuItem
onClick={() =>
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-edit"
onClick={() => onMark(row, "taken")}
data-slot="ledger-menu-taken"
>
<Pencil aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.edit")}
<Check aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.markTaken")}
</DropdownMenuItem>
{(row.status === "skipped" ||
row.status === "taken_on_time" ||
row.status === "taken_late") && (
)}
{actionable && (
<DropdownMenuItem
onClick={() => onMark(row, "skipped")}
data-slot="ledger-mark-skipped"
>
{skipBusy ? (
<Loader2
aria-hidden="true"
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{t("medications.detail.verlauf.markSkipped")}
</DropdownMenuItem>
)}
{canPin && (
<DropdownMenuItem
onClick={() => onPin(intake!.id as string, dueContext!.at)}
data-slot="ledger-pin"
>
<Link2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.action", {
time: fmt.time(new Date(dueContext!.at)),
})}
</DropdownMenuItem>
)}
{canUnpin && (
<DropdownMenuItem
onClick={() => onUnpin(intake!.id as string)}
data-slot="ledger-unpin"
>
<Unlink aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.verlauf.pin.release")}
</DropdownMenuItem>
)}
{intake?.id && (
<>
<DropdownMenuItem
onClick={() =>
onToggleSkipped({
onEdit({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-toggle-skipped"
data-slot="ledger-edit"
>
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward aria-hidden="true" className="mr-2 size-4" />
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
: t("medications.detail.verlauf.markAsSkipped")}
<Pencil aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(intake.id as string)}
className="text-destructive focus:text-destructive"
data-slot="ledger-delete"
>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{(row.status === "skipped" ||
row.status === "taken_on_time" ||
row.status === "taken_late") && (
<DropdownMenuItem
onClick={() =>
onToggleSkipped({
id: intake.id as string,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
data-slot="ledger-toggle-skipped"
>
{intake.skipped ? (
<Check aria-hidden="true" className="mr-2 size-4" />
) : (
<SkipForward
aria-hidden="true"
className="mr-2 size-4"
/>
)}
{intake.skipped
? t("medications.detail.verlauf.markAsTaken")
: t("medications.detail.verlauf.markAsSkipped")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(intake.id as string)}
className="text-destructive focus:text-destructive"
data-slot="ledger-delete"
>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</li>
);
@@ -17,6 +17,7 @@
* never float to the top of the table (O-1).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
@@ -61,6 +62,8 @@ export function IntakeHistoryEditable({
defaultSortBy,
}: IntakeHistoryEditableProps) {
const { t } = useTranslations();
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -132,7 +135,7 @@ export function IntakeHistoryEditable({
return (
<>
<div className="space-y-3" data-slot="intake-history-editable-body">
{selected.size > 0 && (
{canManage && 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"
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useQuery } from "@tanstack/react-query";
import { useState, useMemo } from "react";
import Link from "next/link";
@@ -586,6 +587,9 @@ function IntakeRowActions({
onDeleteIntake?: (eventId: string) => void;
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;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -19,6 +19,7 @@
* the form state + the medication/slot pickers.
*/
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
@@ -116,6 +117,7 @@ export function LogIntakeDialog({
medications,
}: LogIntakeDialogProps) {
const { t } = useTranslations();
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || "Europe/Berlin";
@@ -210,6 +212,7 @@ export function LogIntakeDialog({
...(doseDeviates && { doseTaken: trimmedDose }),
t,
queryClient,
recordName,
// v1.30.1 M7 — a backdated/manual intake now gets the same
// Undo affordance the card's take/skip path already has.
undoIntake: (eventId) =>
@@ -32,6 +32,7 @@
* navigation.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -57,6 +58,13 @@ export function MedicationCardMenu({
onLogSideEffect,
}: MedicationCardMenuProps) {
const { t } = useTranslations();
// v1.36.x — noting a side effect is an admitted delegated write; editing the
// 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 showSideEffect = onLogSideEffect != null && canAdd;
if (!canManage && !showSideEffect) return null;
return (
<DropdownMenu>
@@ -79,17 +87,21 @@ export function MedicationCardMenu({
className="w-56"
data-slot="medication-card-menu"
>
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenHistory}>
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
{onLogSideEffect && (
{canManage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenHistory}>
<History className="mr-2 h-4 w-4" />
{t("medications.detail.header.historyLabel")}
</DropdownMenuItem>
</>
)}
{showSideEffect && onLogSideEffect && (
<>
{canManage && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onLogSideEffect}>
<Stethoscope className="mr-2 h-4 w-4" />
{t("medications.glp1LogSideEffect")}
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
@@ -402,6 +403,9 @@ function MedicationTableRowItem({
// failure toast + Undo. No card-specific follow-up here; the
// injection-site prompt stays a card affordance.
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();
// SAME batched compliance source as the cards.
const { data: compliance } = useMedicationComplianceSummary(medication.id);
@@ -675,7 +679,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active ? (
{medication.active && canAdd ? (
<div className="flex gap-1.5">
<Button
size="icon"
@@ -14,6 +14,7 @@
* redundant link is removed (R-medui §4.3).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -34,6 +35,8 @@ export function IntakeHistoryPreview({
onImportOpenChange,
}: IntakeHistoryPreviewProps) {
const { t } = useTranslations();
// v1.36.x — importing dose history is not a delegated verb.
const { canManage } = useRecordCapabilities();
return (
<>
@@ -42,16 +45,18 @@ export function IntakeHistoryPreview({
title={t("medications.detail.intake.title")}
dataSlot="medication-detail-intake-history-section"
headerExtras={
<Button
variant="outline"
size="sm"
onClick={() => onImportOpenChange(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-import"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
canManage ? (
<Button
variant="outline"
size="sm"
onClick={() => onImportOpenChange(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-import"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
) : null
}
>
<IntakeHistoryEditable medicationId={medicationId} pageSize={14} />
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -111,6 +112,7 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canAdd, canManage } = useRecordCapabilities();
const fmt = useFormatters();
const queryClient = useQueryClient();
@@ -245,17 +247,21 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
({items.length})
</span>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
{/* v1.36.x — noting a side effect is an admitted delegated write.
Removing one that is already noted is not. */}
{canAdd && (
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
)}
</>
);
@@ -340,16 +346,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => setPendingDeleteId(row.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => setPendingDeleteId(row.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</li>
))}
</ul>
@@ -1,5 +1,6 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -94,6 +95,13 @@ export async function runRecordIntake(deps: {
setIntakeLoading: (value: string | null) => void;
undoIntake: (eventId: string) => void | Promise<void>;
onRecorded?: (eventId: string | undefined, skipped: boolean) => void;
/**
* v1.36.x — the record this dose was written into, when it is not the
* caller's own. Two consequences: the toast names it, and the Undo action
* goes. A delegate may record a dose and may not remove one, so an Undo
* they can see is an Undo the server refuses.
*/
recordName?: string | null;
}): Promise<void> {
const {
medication,
@@ -104,6 +112,7 @@ export async function runRecordIntake(deps: {
setIntakeLoading,
undoIntake,
onRecorded,
recordName,
} = deps;
setIntakeLoading(skipped ? "skip" : "take");
@@ -137,14 +146,20 @@ export async function runRecordIntake(deps: {
: "medications.intakeToastTaken",
{ name: medication.name },
),
eventId
recordName
? {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
description: t("recordSharing.toast.savedTo", {
name: recordName,
}),
}
: undefined,
: eventId
? {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
}
: undefined,
);
await invalidateMedicationReads(queryClient);
onRecorded?.(eventId, skipped);
@@ -195,6 +210,8 @@ export async function runLogIntake(deps: {
* carries no action when it's absent.
*/
undoIntake?: (eventId: string) => void | Promise<void>;
/** See {@link runRecordIntake}: names the record, and drops Undo. */
recordName?: string | null;
}): Promise<boolean> {
const {
medication,
@@ -205,6 +222,7 @@ export async function runLogIntake(deps: {
t,
queryClient,
undoIntake,
recordName,
} = deps;
try {
const body: Record<string, unknown> = { skipped };
@@ -230,7 +248,11 @@ export async function runLogIntake(deps: {
// a real Undo action to attach; keeps the no-undo call signature
// identical to the pre-fix behaviour (existing unit tests assert the
// single-argument call).
if (eventId && undoIntake) {
if (recordName) {
toast.success(message, {
description: t("recordSharing.toast.savedTo", { name: recordName }),
});
} else if (eventId && undoIntake) {
toast.success(message, {
action: {
label: t("medications.intakeUndo"),
@@ -273,6 +295,7 @@ export function useMedicationIntake({
}: UseMedicationIntakeParams): UseMedicationIntakeResult {
const queryClient = useQueryClient();
const { t } = useTranslations();
const recordName = useActiveRecordName();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const undoIntake = (eventId: string) =>
@@ -288,6 +311,7 @@ export function useMedicationIntake({
setIntakeLoading,
undoIntake,
onRecorded,
recordName,
});
return { intakeLoading, recordIntake, undoIntake };
@@ -39,6 +39,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import {
useFormatters,
@@ -71,6 +72,10 @@ export function InstrumentCard({
}) {
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
// 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();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -173,11 +178,13 @@ 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. */}
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
</div>
{canManage && (
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
</div>
)}
</CardContent>
</Card>
);
@@ -10,6 +10,7 @@
* landing stays a quiet card grid, so the trend is opt-in behind a
* deliberate click, never pushed at the moment someone arrives to check in.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import {
useFormatters,
@@ -35,6 +36,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();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -73,14 +76,16 @@ export function InstrumentDetail({
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
<Button
type="button"
className="min-h-11 w-full"
onClick={onStart}
data-slot="instrument-detail-start"
>
{t("mentalHealth.start")}
</Button>
{canManage && (
<Button
type="button"
className="min-h-11 w-full"
onClick={onStart}
data-slot="instrument-detail-start"
>
{t("mentalHealth.start")}
</Button>
)}
<AssessmentHistory rows={rows} instrument={instrument} />
+58 -44
View File
@@ -2,6 +2,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
@@ -139,6 +140,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const { t } = useTranslations();
const fmt = useFormatters();
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 queryClient = useQueryClient();
const [moodFilter, setMoodFilterRaw] = useState<string>("ALL");
// v1.15.13 — management-list source filter + optional date range.
@@ -618,7 +622,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
>
{t("mood.emptyResetFilter")}
</Button>
) : onAddFirst ? (
) : onAddFirst && canManage ? (
<Button size="sm" onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("mood.emptyAddFirst")}
@@ -644,18 +648,20 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<TableRow>
<TableHead className="w-10 pl-4">
{/* v1.15.13 — select-all-on-page header checkbox. */}
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
{canManage && (
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
)}
</TableHead>
<SortableHead
column="mood"
@@ -695,11 +701,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
data-state={isSelected ? "selected" : undefined}
>
<TableCell className="pl-4">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
{canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
)}
</TableCell>
<TableCell className="font-semibold tabular-nums">
{entry.score}{" "}
@@ -737,15 +745,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
@@ -789,14 +799,16 @@ 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). */}
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
className="relative after:absolute after:-inset-3.5"
/>
</div>
{canManage && (
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
className="relative after:absolute after:-inset-3.5"
/>
</div>
)}
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span
data-testid="mood-row-score"
@@ -834,15 +846,17 @@ 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. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
iconClassName="h-4 w-4"
@@ -7,6 +7,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
import { accountLabel } from "@/lib/sharing/account-access-view";
import {
useRecordActivity,
@@ -113,7 +114,7 @@ function ActivityRow({ entry }: { entry: RecordActivityEntry }) {
>
<span className="text-foreground text-sm">
{entry.accesses === null
? t("recordSharing.activity.acted", { name: who })
? recordActivityVerbLine(t, entry.action, who)
: t("recordSharing.activity.opened", {
name: who,
count: entry.accesses,
@@ -0,0 +1,85 @@
/**
* What a delegation lets somebody do, pinned as a table.
*
* The hook itself is one line over `useAuth`; the decision lives in
* `resolveRecordCapabilities`, which is pure and has exactly three inputs a
* session can be in. So the contract is enumerated here rather than inferred
* from a rendered page — and every affordance in the sweep binds one of these
* two booleans, which is what makes the sweep provable at all.
*
* Mutation checks, run:
* - return `canManage: true` for a WRITE record → "a delegate may not change
* what is already there" goes red.
* - return `canAdd: true` for a READ record → "a read-only delegate adds
* nothing" goes red.
* - make the `!active` branch return `canAdd: false` → "the caller's own
* record keeps everything" goes red.
*/
import { describe, expect, it } from "vitest";
import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities";
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
const READ_ONLY: AccountAccessEntry = {
accountId: "acct-owner",
username: "owner",
displayName: "Margarethe",
access: "read",
canWrite: false,
};
const WRITABLE: AccountAccessEntry = {
...READ_ONLY,
access: "write",
canWrite: true,
};
describe("resolveRecordCapabilities", () => {
it("the caller's own record keeps everything", () => {
// `null` is not "no access", it is "this is mine". Both an unswitched
// session and a payload from a server that has never heard of sharing
// land here, which is why the absent case must be the permissive one.
expect(resolveRecordCapabilities(null)).toEqual({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
});
expect(resolveRecordCapabilities(undefined)).toEqual(
resolveRecordCapabilities(null),
);
});
it("a read-only delegate adds nothing", () => {
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
inSharedRecord: true,
canWrite: false,
canAdd: false,
canManage: false,
});
});
it("a delegate may not change what is already there", () => {
// The asymmetry the whole design rests on: a WRITE grant adds, and that
// is all it does. Not even to an entry the delegate made a minute ago.
expect(resolveRecordCapabilities(WRITABLE)).toEqual({
inSharedRecord: true,
canWrite: true,
canAdd: true,
canManage: false,
});
});
it("binds the server's boolean rather than the grant's label", () => {
// `access` is descriptive; `canWrite` is the resolved decision. A row that
// says "write" while the server resolved false (lapsed, revoked mid-flight,
// a level this build does not honour) must read as read-only, because two
// programs deciding one person's access is how they end up disagreeing.
const disagreeing: AccountAccessEntry = {
...READ_ONLY,
access: "write",
canWrite: false,
};
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
});
});
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import {
accountLabel,
type AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
/**
* What the person at the keyboard may do to the record on screen.
*
* ## Why this exists
*
* v1.36.0 shipped account sharing read-only, and everything that consulted
* the grant lived in the application chrome: the nav bars, the banner, the
* switcher. No feature surface asked. So a delegate opened `/measurements`
* inside somebody else's record, saw the add button, filled the form, and was
* refused by the server. Nothing leaked, because the refusal was right. It
* still taught them the product was broken. This hook is what every mutation
* affordance asks before it renders.
*
* ## The two answers, and why there are two
*
* A grant at WRITE admits a short, closed list of verbs: entering readings,
* lab results, allergies, family history, an illness entry, a biomarker, a
* tracked value, a side effect, a medication, and marking a dose taken. It
* admits nothing else. Editing, deleting, restoring, purging, importing and
* bulk-acting stay with the owner, including on an entry the delegate added
* themselves a minute ago, and so does every create the list does not name
* (a mood entry, a screener, a cycle day). Two booleans carry that:
*
* - `canAdd` — may this person add one of the admitted kinds.
* - `canManage` — may this person change what is already there, or add
* something the delegation does not cover. True only in one's own record.
*
* A surface that offers an admitted create asks `canAdd`. Everything else
* asks `canManage`. Neither is a permission decision made here: `canWrite`
* arrives resolved from the server on `accountAccess.active` and is bound,
* never recomputed (`account-access-view.ts:11-15`). What this file decides is
* only which class of affordance a resolved level covers, and that mapping is
* a property of the release, not of the caller.
*
* ## Absent, not disabled
*
* The consuming rule, stated once here because it belongs to the whole sweep:
* a control this returns `false` for does not render at all. A greyed-out
* button still says "this exists and does not work for you"; a control that is
* simply not there says "this is not part of what you were given", which is
* the truth. The banner already states the mode globally, so no per-control
* chip explains the absence — only an empty state a person would otherwise
* search a button in gets a sentence.
*/
export interface RecordCapabilities {
/** Is this browser acting on somebody else's record right now. */
inSharedRecord: boolean;
/** The grant's resolved level for the record on screen. */
canWrite: boolean;
/** May the caller add an entry of a kind the delegation admits. */
canAdd: boolean;
/** May the caller change what exists, or add what the delegation excludes. */
canManage: boolean;
}
/**
* The mapping, as a pure function of the record the session is inside.
*
* Exported so its contract can be pinned without a click: the three states a
* session can be in are enumerable, and the test enumerates them. `null` is
* one's own record, which is every capability.
*/
export function resolveRecordCapabilities(
active: AccountAccessEntry | null | undefined,
): RecordCapabilities {
if (!active) {
return {
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
};
}
return {
inSharedRecord: true,
canWrite: active.canWrite,
canAdd: active.canWrite,
canManage: false,
};
}
/**
* The record on screen, and what may be done to it.
*
* Reads the same resolved block the banner and the switcher read, so the
* chrome and the controls beneath it cannot disagree about what a delegate
* was given.
*
* Before `/api/auth/me` settles, `active` is absent and the answer is the
* caller's own record. That is deliberate rather than fail-closed: failing
* closed would blank every add button in the app for every account on every
* cold load, to spare a delegate one frame. The switch flow ends in a hard
* reload, so a delegate's first paint can show an add control for as long as
* the account query takes, and then it goes. A control that appears and
* withdraws is a much smaller lie than one that stays and 403s, and the
* banner lands from the same query in the same frame.
*/
export function useRecordCapabilities(): RecordCapabilities {
const { user } = useAuth();
return resolveRecordCapabilities(user?.accountAccess?.active);
}
/**
* The name of the record this browser is inside, or null in one's own.
*
* The delegate's receipt: a mutation that lands under a switch says which
* record it landed in, because "Saved" alone is the one confirmation a person
* acting for somebody else does not need. Kept beside the capabilities because
* it reads the same resolved entry and would otherwise be a fourth place that
* decides what to call somebody.
*/
export function useActiveRecordName(): string | null {
const { user } = useAuth();
const active = user?.accountAccess?.active ?? null;
return active ? accountLabel(active) : null;
}
@@ -0,0 +1,77 @@
/**
* The owner's ledger says what was done, not that something was.
*
* Mutation checks, run:
* - drop the `medications.intake.update` case → "each admitted verb has its
* own line" goes red on that action.
* - make the default arm throw instead of falling back → "an action this
* release has never heard of still renders" goes red.
*/
import { describe, expect, it } from "vitest";
import enMessages from "../../../../messages/en.json";
import { recordActivityVerbLine } from "../activity-verb";
/** Resolve a dotted key against the real EN bundle, params interpolated. */
function translate(
key: string,
params?: Record<string, string | number>,
): string {
const value = key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
enMessages,
);
if (typeof value !== "string") throw new Error(`missing key: ${key}`);
return Object.entries(params ?? {}).reduce(
(text, [k, v]) => text.replaceAll(`{${k}}`, String(v)),
value,
);
}
/**
* Every action the eleven admitted write verbs can file, with the audit
* strings the handlers actually pass to `auditLog()`.
*/
const ADMITTED_ACTIONS = [
"measurement.create",
"labResult.create",
"allergy.create",
"family-history.create",
"illness.episode.create",
"biomarker.create",
"customMetricEntry.create",
"medication.sideEffect.create",
"medication.create",
"medications.intake.update",
] as const;
describe("recordActivityVerbLine", () => {
it("each admitted verb has its own line", () => {
const generic = translate("recordSharing.activity.acted", { name: "Anna" });
const lines = ADMITTED_ACTIONS.map((action) =>
recordActivityVerbLine(translate, action, "Anna"),
);
for (const line of lines) {
// The point of the map: none of them may fall through to "made a
// change", which is the line the owner already had.
expect(line).not.toBe(generic);
expect(line).toContain("Anna");
}
// And no two verbs share a line — a map that answered "added something"
// ten times would pass the check above and say nothing.
expect(new Set(lines).size).toBe(ADMITTED_ACTIONS.length);
});
it("an action this release has never heard of still renders", () => {
// Audit rows outlive this map: a later release files verbs it does not
// know, and older rows sit in the same table. Vague beats blank.
expect(
recordActivityVerbLine(translate, "future.thing.create", "Anna"),
).toBe(translate("recordSharing.activity.acted", { name: "Anna" }));
});
});
+62
View File
@@ -0,0 +1,62 @@
/** The `t` from `useTranslations()`, injected so this stays pure. */
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* What somebody did in your record, in a sentence.
*
* ## Why this exists
*
* The activity view renders every non-read row as one generic line: "somebody
* made a change in your record". That was honest while sharing was read-only
* and no delegate could make one. Now a delegate can enter a reading, a lab
* result, an allergy, a family-history entry, an illness entry, a biomarker,
* a tracked value, a side effect, a medication, and can mark a dose taken —
* and "made a change" for ten different things is a way of answering nothing.
* An owner reading this at breakfast wants the verb.
*
* ## Why unknown actions fall back rather than fail
*
* The action string comes from the audit row, which outlives this map: a
* release that admits a twelfth verb writes rows this map has never heard of,
* and rows written by an older image sit in the same table. So an unmapped
* action renders the generic line it renders today. A blank row would be the
* one outcome worse than a vague one.
*
* Pure and translator-injected so the mapping can be pinned without rendering
* a card, and so the keys stay literal for the i18n call-site guard.
*/
export function recordActivityVerbLine(
t: Translator,
action: string,
name: string,
): string {
switch (action) {
case "measurement.create":
return t("recordSharing.activityVerb.measurementCreate", { name });
case "labResult.create":
return t("recordSharing.activityVerb.labResultCreate", { name });
case "allergy.create":
return t("recordSharing.activityVerb.allergyCreate", { name });
case "family-history.create":
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
case "illness.episode.create":
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
case "biomarker.create":
return t("recordSharing.activityVerb.biomarkerCreate", { name });
case "customMetricEntry.create":
return t("recordSharing.activityVerb.customMetricEntryCreate", { name });
case "medication.sideEffect.create":
return t("recordSharing.activityVerb.medicationSideEffectCreate", {
name,
});
case "medication.create":
return t("recordSharing.activityVerb.medicationCreate", { name });
case "medications.intake.update":
return t("recordSharing.activityVerb.medicationIntake", { name });
default:
return t("recordSharing.activity.acted", { name });
}
}