diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts new file mode 100644 index 000000000..34bd0c476 --- /dev/null +++ b/e2e/delegated-writes.spec.ts @@ -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); + }); +}); diff --git a/messages/de.json b/messages/de.json index 2fb451b33..0fc8904c9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" } } } diff --git a/messages/en.json b/messages/en.json index 2dafb2086..0862555c9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } } diff --git a/messages/es.json b/messages/es.json index ba7a5108d..e43bd86cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" } } } diff --git a/messages/fr.json b/messages/fr.json index d8ec7de9d..8a1ea5002 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" } } } diff --git a/messages/it.json b/messages/it.json index 68706b562..c6a371967 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" } } } diff --git a/messages/pl.json b/messages/pl.json index 7485f242c..27135a58e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" } } } diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 75d1669f8..23bc53a73 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -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, }, diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 737a20f75..78adf9925 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -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. */} - + +