diff --git a/.gitignore b/.gitignore index b470644a6..e5c925bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ /playwright-report /playwright/.cache /e2e/setup/storageState.json +/e2e/setup/storageStateOwner.json +/e2e/setup/storageStateDelegate.json # next.js /.next/ diff --git a/docs/README.md b/docs/README.md index 7d83a7001..084dd655c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,12 @@ notes, and machine-readable specs that ship alongside the source: `OIDC_ONLY` consequences, and the break-glass runbook. Also [`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending - login IPs to a third-party lookup. + login IPs to a third-party lookup. And + [`self-hosting/account-sharing.md`](./self-hosting/account-sharing.md) — + letting one account on the instance open another's health record: what + the person given access can and cannot reach, what revocation does and + does not undo, and which trust properties of the instance are unchanged + by it. - [`migration/`](./migration/) — release-by-release migration notes. Read the entry for the version you're upgrading from. - [`audit/`](./audit/) — per-release audit summaries archived for diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0a47f82c4..82ed9e9f0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12432,6 +12432,61 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me: + get: + tags: + - Auth + summary: The signed-in account, and what sharing lets it do + description: "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose + record is open, and the way back out all read it, so a refusal here would strand a switched session. It is + therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches + `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that + wants a record's data sends the selector on the read that needs it, never on this call." + responses: + "200": + description: The account payload. + content: + application/json: + schema: + $ref: "#/components/schemas/AccountPayloadEnvelope" + "401": *a1 + "403": + description: "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/account/activity: + get: + tags: + - Account sharing + summary: Who opened this record, and when + description: "Every audit row filed under this account that somebody else performed, newest first — one row per delegate + per day with a count, rather than one per request. `retentionDays` rides the response because audit history is + purged on a schedule the operator configures: a view that showed the rows without the window would imply a + completeness it does not have, and clients must state the number the server sends rather than assume the 365-day + default. Not delegable: a grant is not an introduction to the other people in the household." + responses: + "200": + description: The activity, and how far back it can see. + content: + application/json: + schema: + $ref: "#/components/schemas/RecordActivityEnvelope" + "401": *a1 + "403": &a20 + description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another + account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the + account they named. The latter is byte-identical for an account that does not exist and one that granted + nothing — the refusal is not an enumeration oracle. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/account/grants: get: tags: @@ -12446,15 +12501,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": &a20 - description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another - account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the - account they named. The latter is byte-identical for an account that does not exist and one that granted - nothing — the refusal is not an enumeration oracle. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a20 "422": *a3 "429": *a4 post: @@ -36558,6 +36605,186 @@ components: - data - error additionalProperties: false + AccountPayloadEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AccountPayload" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AccountPayload: + type: object + properties: + id: + type: string + username: + type: string + accountAccess: + $ref: "#/components/schemas/AccountAccess" + required: + - id + - username + - accountAccess + additionalProperties: {} + description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. + Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this + payload still describes the CALLER — their preferences, their modules, their identity — because display + preferences belong to the person at the keyboard rather than to the record they are reading." + AccountAccess: + type: object + properties: + accounts: + type: array + items: + $ref: "#/components/schemas/AccountAccessEntry" + description: Every record this caller may open, newest grant first. + active: + anyOf: + - $ref: "#/components/schemas/AccountAccessEntry" + - type: "null" + description: "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a + banner can name the person without joining two fields. Null when the caller is in their own record, and + ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses + one." + canSwitch: + type: boolean + description: Whether there is anywhere to switch to. Bind it directly. + required: + - accounts + - active + - canSwitch + additionalProperties: false + description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server + publishes what the caller may do and the client renders it. A client that computed either from grant data would + be a second program deciding one person's access to a health record, and the two answer differently the first + time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, + not a missing field." + AccountAccessEntry: + type: object + properties: + accountId: + type: string + description: The account whose record it is — the value POST /api/account/switch takes, and the value the + X-HealthLog-Account header carries. + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + access: + type: string + enum: + - read + - write + canWrite: + type: boolean + description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; + never derive it. + required: + - accountId + - username + - displayName + - access + - canWrite + additionalProperties: false + description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL + here would resolve to a refusal. Clients paint their initials fallback." + RecordActivityEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RecordActivity" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RecordActivity: + type: object + properties: + entries: + type: array + items: + $ref: "#/components/schemas/RecordActivityEntry" + retentionDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by + default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it + beside the rows. + required: + - entries + - retentionDays + additionalProperties: false + description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention. + RecordActivityEntry: + type: object + properties: + id: + type: string + actor: + anyOf: + - $ref: "#/components/schemas/AccountGrantParty" + - type: "null" + description: Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign + key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as + though the owner had done it themselves. + action: + type: string + accesses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: How many times that delegate opened the record on that day, for a coalesced read row. Null for anything + else — a single act has no count, and a 1 there would invite adding up numbers that mean different things. + createdAt: + type: string + required: + - id + - actor + - action + - accesses + - createdAt + additionalProperties: false + AccountGrantParty: + type: object + properties: + id: + type: string + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + required: + - id + - username + - displayName + additionalProperties: false + description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a + way to collect the other party's contact details." AccountGrantListEnvelope: type: object properties: @@ -36647,24 +36874,6 @@ components: accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back. `lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised." - AccountGrantParty: - type: object - properties: - id: - type: string - username: - type: string - displayName: - anyOf: - - type: string - - type: "null" - required: - - id - - username - - displayName - additionalProperties: false - description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a - way to collect the other party's contact details." AccountGrantState: type: string enum: @@ -37272,6 +37481,23 @@ components: - showEvidenceByDefault - defaultWindow additionalProperties: false + parameters: + AccountSelector: + name: X-HealthLog-Account + in: header + required: false + schema: + type: string + maxLength: 64 + description: "Act on another account's record for THIS request. Bearer transport only — the browser session carries its + switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value + is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the + grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a + selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to + one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the + sync engine, device or notification registration, or any auth/refresh call: those are about the person holding + the token, not the record being read, and the account payload refuses the header outright for exactly that + reason." securitySchemes: bearerAuth: type: http diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md new file mode 100644 index 000000000..46222f719 --- /dev/null +++ b/docs/self-hosting/account-sharing.md @@ -0,0 +1,146 @@ +# Sharing a health record with another account + +Two accounts on the same instance can be connected so that one of them +can open the other's health record. A parent and an adult child, two +partners, somebody keeping an eye on a relative's blood pressure — the +case this exists for is a household, not an organisation. + +This page describes what that connection does, what it deliberately does +not do, and which of the instance's existing trust properties it leaves +exactly as they were. + +## The shape of it + +One account **shares**; the other is **given access**. In the code and in +the settings copy these are the owner and the person they invited. + +- The owner opens **Settings → Shared access** and invites somebody by + the username or e-mail address that person signs in with. They need an + account on this instance already; there is no invite-by-e-mail flow. +- **The invitation confers nothing.** Until the other person accepts it, + it is an offer sitting in their own settings. Being handed read access + to somebody's health record is not something the product imposes + silently, and the acceptance — with its timestamp and the accepting IP — + is what the row records. +- Once accepted, the record shows up in that person's user menu under + "Open a record". Opening it reloads the app into the owner's record. +- A banner across the top of every page names whose record is open and + says it is read-only, with a button back to the person's own. It is + loud on purpose: somebody who forgets they are switched will log their + own reading into another person's record, and there is no undo for an + entry that was never theirs. +- Either side can end it at any time. The owner withdraws access; the + other person hands it back. Both are one click with no second factor + and no typed confirmation — reducing access must never be harder than + granting it was. + +## What the other person can and cannot do + +**Read the health record.** Measurements, medications, mood, labs, the +illness journal, preventive-care reminders, documents. In this release +that is the whole of it: every grant is read-only. The write level exists +in the data model so that adding it later is enforcement work rather than +a migration, and no invitation can create one today. + +**Nothing about the account around the record.** This is the line the +whole feature is built on, and it is drawn on the server: + +| Not reachable | Why it would matter | +| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Password, passkeys, second factor, recovery codes | Silent takeover, surviving any revocation | +| Sessions, trusted devices, sign-out-everywhere | Ending or enumerating the owner's logins | +| API tokens, the MCP connector | A durable credential that outlives the access | +| Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record | +| Notification channels and push devices | The owner's health alerts redirected to another phone or chat | +| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access | +| Insights and the Coach | AI processing of the owner's data, under the owner's own consent | +| Sharing itself | Inviting somebody else, widening the access, or passing it on | +| Locale, theme, dashboard layout | Presentation belongs to the person, not to the record | + +The last two rows are worth a sentence each. Sharing is not +transferable: somebody given access cannot invite a third person, cannot +raise their own level, and cannot hand it on. And display preferences +stay with the person at the keyboard, so reading somebody else's record +does not change your own theme, language or dashboard. + +The AI row is the one that looks arbitrary and is not. Server-managed AI +features send the record's data to a provider under a consent the record's +OWNER gave, for their own use. Somebody else triggering that would create +a consent-shaped act the owner never made, so those surfaces stay +owner-only. + +These are not checks that a page forgot to render. Every route in the +product refuses by default while a browser is acting on another account; +a route becomes reachable only by declaring it, and the set of declared +routes is frozen by a test. + +## Revocation, and what it cannot undo + +Ending access takes effect on the other person's **next request**, not at +their next login. The grant is checked against the database on every +single request, so there is no cached verdict to wait out. If their +browser is sitting inside the record when the owner ends it, the same +database transaction puts that browser back into its own account — they +land at home rather than on a wall of refusals. + +What revocation cannot do is take back what was already seen. A person +who had read access could have read, remembered, screenshotted or written +down anything in the record. The product can close the door; it cannot +reach through it. Nothing in the interface implies otherwise, and neither +does this page. + +Ending access never deletes the record of it. The row stays with the +dates and with who ended it, because "who had access, from when to when, +and who ended it" is a question a deleted row cannot answer. Re-inviting +the same person creates a new row beside the old one. + +## Seeing what happened + +**Settings → Shared access** answers three questions for the owner, at +any time: who can open this record, since when, and when they last did. +Below that, a list of the days somebody else opened it and how many times +— one line per person per day, rather than a line per request. + +That list is bounded by the instance's audit retention, and the page says +so with the actual number rather than a hardcoded one. The default is 365 +days (`AUDIT_LOG_RETENTION_DAYS`); an operator who shortens it shortens +this view too, and the sentence under the heading changes to match. An +empty list means nobody opened the record within that window — not that +nobody ever did. + +## What this does not change about the instance + +Stated plainly, because a sharing feature is exactly the moment somebody +re-reads the trust model: + +- **The operator could always read the database.** Anyone with server or + database access, and anyone with an admin account on this instance, can + reach health data directly. That was true before sharing existed and is + unchanged by it. On a household instance the operator is usually one of + the people using it; on an instance hosting other people, it is worth + saying out loud. +- **Encryption at rest is instance-keyed, not per-user.** The encrypted + columns are protected by the key in `ENCRYPTION_KEYS`, which belongs to + the instance. Sharing does not weaken that, and it never did protect one + account's data from the operator of the same instance. +- **AI processing stays under the record owner's consent**, because the AI + surfaces are not part of what sharing covers. + +## For operators + +Nothing to configure. Sharing is available on every instance, needs no +environment variable, and does nothing until one account invites another. + +Two operational notes: + +- **Invitations disclose whether a username exists.** An invitation to a + name nobody uses is refused with "no account with that name". That is + deliberate: the alternative is a silent pending row that will never be + accepted because the owner mistyped. The caller is already + authenticated and the endpoint allows ten invitations an hour per + account, which bounds how fast the surface could be walked. +- **Deleting an account** removes the grants it is party to. The audit + rows naming that account as the one who opened somebody's record stay, + and render as "a deleted account" — nulling them would turn "your + daughter opened your record" into "you opened your record", which is + worse than an unresolvable id. diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts new file mode 100644 index 000000000..a7d966212 --- /dev/null +++ b/e2e/account-sharing.spec.ts @@ -0,0 +1,349 @@ +/** + * Account sharing, end to end: invite → accept → switch → banner → revoke. + * + * Two browser contexts, two real accounts, one real database. The owner + * invites, the delegate accepts and opens the record, and the owner takes it + * back — each step through the shipped UI rather than through the API, because + * the half of this feature that can go wrong is the half a person touches. + * + * ## The stale-paint check + * + * The assertion this spec exists for is a NEGATIVE one, and it is deliberately + * not "the new record's data arrives". A check that waits for the new data + * proves nothing about what was on screen while it was arriving, and the + * failure being guarded against is exactly that in-between frame: a dashboard + * hydrated from the previous record's IndexedDB snapshot, painted under a + * banner naming somebody else. + * + * So an init script installs a MutationObserver before any page script runs, + * on every navigation including the full reload the switch performs, and it + * latches the moment the delegate's own marker weight appears in the document. + * The spec then reads the latch. Nothing about it is sampled or timed. + * + * The observer's positive control runs first: with the same script installed, + * the delegate's own measurements page must trip the latch. Without that, "the + * marker never appeared" and "the observer never worked" are the same sentence. + * + * ### How much this assertion can be made to fail, measured rather than assumed + * + * Three controls stand behind it, and they were removed one at a time and + * together against a real build to see which of them this spec can actually + * detect. The honest answer is: none of them individually, and that is worth + * writing down rather than leaving as an implication. + * + * - Delete the switch-time cache wipe, the persister's restore guard and the + * record-scoped query hash — all three at once — and this spec stays + * GREEN. The reason is a rule that predates this feature: the restore + * refuses to clobber a query the live cache already holds, and the + * dashboard's own query is created during render, before any effect runs. + * So the IndexedDB snapshot never reaches the screen on this route at all. + * - Delete the full reload and the spec goes RED, but on the banner line + * rather than the marker line: without a reload the account payload never + * re-resolves and the banner never paints, so the run stops before the + * latch is read. + * + * What that means, plainly: this spec is a regression net over the whole + * journey, not a proof of any single control. The controls are proven where + * they can be broken and observed — `query-persister-shared-record.test.ts` + * for the two disk guards, `record-scope.test.ts` for the cache dimension — + * and each of those was verified red before being kept. + * + * ## What this spec cannot cover, stated rather than implied + * + * - **The service worker.** The Playwright context runs with + * `serviceWorkers: "block"` (see `playwright.config.ts`), so the worker's + * `healthlog-data-*` cache — the other layer that survives a reload — is not + * exercised here at all. The switch wipes it through the same helper the + * IndexedDB snapshot goes through, and that helper is covered in + * `src/lib/pwa/__tests__/query-persister-shared-record.test.ts`. + * - **The owner's data appearing.** No route declares the delegable mode yet, + * so every read inside a shared record is refused by design. That is the + * current, correct state of the tree; the pages a delegate can open show + * their refusal state and, crucially, still show nothing of the delegate's + * own. The positive half of that assertion belongs with the route migration. + */ +import type { BrowserContext, Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { + DELEGATE_STORAGE_STATE_PATH, + E2E_DELEGATE_MARKER_KG, + E2E_OWNER, + E2E_USER, + OWNER_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; + +const DELEGATE_MARKER = String(E2E_DELEGATE_MARKER_KG); + +/** + * Latch the first appearance of a string anywhere in the document, from before + * the first script of every page load. + * + * A MutationObserver rather than a poll: a poll samples, and the frame this is + * looking for can be one paint long. `characterData` is on because a hydration + * pass can fill an existing text node in place rather than inserting one. + */ +async function watchForText(page: Page, needle: string): Promise { + await page.addInitScript((text: string) => { + const w = window as unknown as { __sawText?: boolean }; + w.__sawText = false; + const seen = () => document.body?.innerText?.includes(text) ?? false; + const check = () => { + if (seen()) w.__sawText = true; + }; + const start = () => { + check(); + new MutationObserver(check).observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } + }, needle); +} + +async function sawText(page: Page): Promise { + return page.evaluate( + () => (window as unknown as { __sawText?: boolean }).__sawText === true, + ); +} + +/** Open the user menu and the switcher inside it. */ +async function openSwitcher(page: Page): Promise { + 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(); +} + +test.describe("account sharing", () => { + // One journey, in order: each step is the precondition of the next, so + // splitting them into independent tests would mean re-running the invitation + // three times against a rate-limited endpoint. + test.describe.configure({ mode: "serial" }); + + // The `page` fixture throughout is the DELEGATE. The owner gets an explicit + // context below, because a grant needs two signed-in sides at once and the + // login endpoint is IP-rate-limited. + // + // The delegate's jar is this journey's OWN, not the shared one every other + // authenticated spec reads, and that is load-bearing rather than tidy. The + // switch is stamped on the session row (see "the nav offers only what + // sharing covers" below, which relies on exactly that), so switching the + // shared jar would switch it for every spec holding the same cookie — and + // the suite runs fully parallel. The damage lands somewhere else entirely: + // a settings surface painting "Not part of shared access", an axe scan + // reading a banner its spec never asked for. Same account, different + // session, no reach outside this file. + test.use({ storageState: DELEGATE_STORAGE_STATE_PATH }); + + // Desktop only, and for a reason that is about the fixture rather than the + // layout: this journey mutates shared rows — one grant between two seeded + // accounts — so two projects running it at once race each other, and the + // second one's invitation is refused as a duplicate of the first's. Running + // it once keeps every step's precondition true. The mobile bar reads the + // same destination model as the sidebar, and that agreement is a unit + // contract (`nav-model-shared-record.test.ts`) rather than something two + // browser runs of the same script would establish. + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "shared-fixture journey — runs once", + ); + }); + + let ownerContext: BrowserContext; + let ownerPage: Page; + + test.beforeAll(async ({ browser }) => { + ownerContext = await browser.newContext({ + storageState: OWNER_STORAGE_STATE_PATH, + }); + ownerPage = await ownerContext.newPage(); + }); + + test.afterAll(async () => { + await ownerContext.close(); + }); + + test("the owner invites, and the invitation confers nothing yet", async () => { + await ownerPage.goto("/settings/access"); + + // The submit button is disabled until the controlled input holds a value, + // so a `fill()` that lands before React has attached its listeners leaves + // the form looking filled and behaving empty. Retry the pair until the + // button agrees, rather than waiting a fixed time and hoping. + const identifier = ownerPage.locator( + '[data-slot="grant-invite-identifier"]', + ); + const submit = ownerPage.locator('[data-slot="grant-invite-submit"]'); + await expect(async () => { + await identifier.fill(E2E_USER.username); + await expect(submit).toBeEnabled({ timeout: 1000 }); + }).toPass({ timeout: 15_000 }); + await submit.click(); + + const row = ownerPage + .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') + .first(); + await expect(row).toBeVisible(); + // Pending, not active. The state is the server's answer, rendered. + await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + }); + + test("the delegate is offered nothing until they accept", async ({ + page, + }) => { + await page.goto("/settings/access"); + + const row = page + .locator('[data-slot="grants-received-list"] [data-slot="grant-row"]') + .first(); + await expect(row).toBeVisible(); + await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + + // No switcher yet: `accountAccess.canSwitch` is false while the only + // grant is pending, and the menu binds that boolean. + await page.getByRole("button", { name: "User menu" }).first().click(); + await expect( + page.locator('[data-slot="account-switcher-trigger"]'), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.locator('[data-slot="grant-accept"]').click(); + await expect(row).toHaveAttribute("data-grant-state", "ACTIVE"); + }); + + test("the switcher appears once the grant is live", async ({ page }) => { + await page.goto("/settings/access"); + await openSwitcher(page); + + const entry = page.locator('[data-slot="account-switcher-entry"]'); + await expect(entry).toHaveCount(1); + await expect(entry).toContainText(E2E_OWNER.username); + // The way home is in the same menu as the way in. + await expect( + page.locator('[data-slot="account-switcher-own"]'), + ).toBeVisible(); + }); + + test("switching shows the banner and never the delegate's own data", async ({ + page, + }) => { + await watchForText(page, DELEGATE_MARKER); + + // Positive control. If the observer cannot see the marker on the page + // that definitely shows it, its silence later means nothing. + await page.goto("/measurements"); + await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible(); + expect(await sawText(page)).toBe(true); + + // Let the offline persister flush the delegate's own dashboard to + // IndexedDB (1 s debounce). That snapshot is the stale-paint source this + // whole assertion is aimed at — without it the check would pass against a + // disk that was empty anyway. + await page.goto("/"); + await page.waitForTimeout(2000); + + await openSwitcher(page); + await page.locator('[data-slot="account-switcher-entry"]').click(); + + // Wait for CONTENT, and for content the assertion is not about: the + // banner naming the owner. A skeleton does not satisfy it, and it says + // nothing about the marker either way. + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(E2E_OWNER.username); + + // The document showing the banner has never shown the delegate's own + // number. The latch was armed by that document's own init script, so this + // covers its whole life rather than the instant it is read. See the + // header for what this line can and cannot be made to fail on. + expect(await sawText(page)).toBe(false); + + // And it stays false across a navigation inside the record. + await page.goto("/measurements"); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + expect(await sawText(page)).toBe(false); + }); + + test("the nav offers only what sharing covers", async ({ page }) => { + // A fresh page fixture, and still inside the record: the switch lives on + // the session row rather than in the tab, so every tab of that browser is + // switched at once. That is the property this navigation quietly proves. + await page.goto("/"); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // The record itself is still there… + await expect(page.locator('a[href="/measurements"]').first()).toBeVisible(); + // …and everything that configures the account around it is not. + await expect(page.locator('a[href="/settings/account"]')).toHaveCount(0); + await expect(page.locator('a[href="/coach"]')).toHaveCount(0); + await expect(page.locator('a[href="/insights"]')).toHaveCount(0); + }); + + test("a surface sharing does not cover says so", async ({ page }) => { + // Reached by bookmark: the nav entry is gone, the URL still resolves. + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="shared-record-unavailable-leave"]'), + ).toBeVisible(); + }); + + test("revoking from the owner's browser drops the delegate out", async ({ + page, + }) => { + await page.goto("/"); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // The owner ends it from their own session, in their own context. + await ownerPage.goto("/settings/access"); + await ownerPage.locator('[data-slot="grant-revoke"]').first().click(); + await ownerPage + .getByRole("button", { name: "End access", exact: true }) + .last() + .click(); + await expect( + ownerPage + .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') + .first(), + ).toHaveAttribute("data-grant-state", "REVOKED"); + + // The delegate's next navigation lands back in their own account rather + // than on a wall of refusals: the revoke cleared their acting session in + // the same transaction that stamped the row. + await page.goto("/measurements"); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + // Their own record, back — which is also the proof that the page did not + // simply fail to load. + await expect(page.getByText(DELEGATE_MARKER).first()).toBeVisible(); + }); + + test("the ended grant stays on the record as history", async () => { + await ownerPage.goto("/settings/access"); + const row = ownerPage + .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') + .first(); + // Revocation never deletes. "Who had access, from when to when, and who + // ended it" is the question the panel exists to answer. + await expect(row).toHaveAttribute("data-grant-state", "REVOKED"); + await expect(row).toContainText("You ended it"); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 247df5c4f..80d3765d3 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -50,6 +50,61 @@ export const E2E_USER = { role: "ADMIN", } as const; +/** + * v1.36.0 — the SECOND account, so account sharing has two sides to test. + * + * Sharing is the one feature in the product that cannot be exercised by one + * person: somebody has to own a record and somebody else has to be given it. + * This account owns the record; `E2E_USER` is the delegate throughout the + * sharing spec. + */ +export const E2E_OWNER = { + email: "e2e-owner@healthlog.test", + username: "e2e-owner", + password: "Qv7bR2n!Ks9wLd4T", + role: "USER", +} as const; + +/** + * Weights seeded one per account, chosen so neither can be mistaken for the + * other in rendered markup and neither collides with a value any other spec + * writes. + * + * The delegate's number is the whole point of the stale-paint check: after + * switching into the owner's record it must appear NOWHERE, at no moment. A + * spec that only waited for the owner's data to arrive would prove nothing + * about what was on screen in between. + */ +export const E2E_DELEGATE_MARKER_KG = 77.7; +export const E2E_OWNER_MARKER_KG = 63.3; + +export const OWNER_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateOwner.json", +); + +/** + * A SECOND cookie jar for the same delegate account, and the reason it exists + * is the one property that makes account switching work at all: the switch is + * stamped on the session row, not held in the tab. Every session opened from + * `STORAGE_STATE_PATH` is therefore the same session — so a spec that switches + * that jar into somebody else's record switches it for every spec that + * authenticates with it, whichever of them happens to be mid-navigation on the + * other worker. + * + * The symptom is a wandering one: `/settings/ai` painting "Not part of shared + * access", an axe scan reading a banner nobody asked for, a `waitForResponse` + * on a request the refused surface never sends. Nothing in the failing spec is + * wrong and nothing in its file explains it. + * + * So the journey that switches gets its own login, and the shared jar is never + * pointed anywhere. Same account, different session row, no overlap. + */ +export const DELEGATE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateDelegate.json", +); + async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, @@ -179,6 +234,85 @@ export default async function globalSetup(config: FullConfig): Promise { [cuid(), now, E2E_USER.username], ); + // ── v1.36.0 — the sharing fixture ──────────────────────────────────── + // + // A second account, one distinctive weight in each record, and a clean + // grant table. The reset matters as much as the seed: grants and switch + // stamps outlive a run, and a second run starting with the delegate + // already inside the owner's record would skip the invitation half of + // the journey while still going green. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, + created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + [ + cuid(), + E2E_OWNER.username, + E2E_OWNER.email, + await hashPassword(E2E_OWNER.password), + now, + ], + ); + + // Every grant between the pair, and any switch stamp either of them is + // carrying. Deleted rather than revoked: this is fixture hygiene, not a + // lifecycle transition, and a revoked row would still show in the panel. + await pool.query( + `DELETE FROM account_grants + WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1)) + OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`, + [[E2E_USER.username, E2E_OWNER.username]], + ); + await pool.query( + `UPDATE sessions SET acting_as_user_id = NULL + WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`, + [[E2E_USER.username, E2E_OWNER.username]], + ); + await pool.query( + `DELETE FROM rate_limits + WHERE key LIKE 'sharing:%'`, + ); + + // The login bucket, for the same reason. This setup signs in THREE times + // now (the shared jar, the owner, and the sharing journey's own delegate + // jar), and the ceiling is five attempts per IP per quarter-hour — so two + // local runs in a row would otherwise end with a 429 from the fixture + // rather than a failure from the product. Only the auth surfaces' own + // buckets are cleared; nothing else in the table is touched. + await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); + + // One marker weight per record. Re-seeded by delete-then-insert so a + // re-run cannot accumulate duplicates and so an edited constant takes + // effect rather than sitting beside the old value. + for (const [username, kg] of [ + [E2E_USER.username, E2E_DELEGATE_MARKER_KG], + [E2E_OWNER.username, E2E_OWNER_MARKER_KG], + ] as const) { + await pool.query( + `DELETE FROM measurements + WHERE user_id = (SELECT id FROM users WHERE username = $1) + AND external_id = 'e2e-sharing-marker'`, + [username], + ); + await pool.query( + `INSERT INTO measurements + (id, user_id, type, value, unit, source, measured_at, + external_id, created_at, updated_at) + SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3, + 'e2e-sharing-marker', $3, $3 + FROM users u WHERE u.username = $4`, + [cuid(), kg, now, username], + ); + } + // Console (instead of structured logging) is intentional here — // global-setup runs outside the app's logging context, and the // line is useful when debugging a CI failure where the seed didn't @@ -190,60 +324,82 @@ export default async function globalSetup(config: FullConfig): Promise { await pool.end(); } - // Login once and persist the resulting cookie jar so every spec can - // reuse it via `playwright.config.ts → use.storageState`. This is the - // documented Playwright pattern (`docs/auth.md`) and the only way to - // avoid the per-spec login + rate-limit dance. + // Log the fixture accounts in here and persist their cookie jars so specs + // can reuse them via `test.use({ storageState })`. This is the documented + // Playwright pattern (`docs/auth.md`) and the only way to avoid the per-spec + // login + rate-limit dance. const baseURL = config.projects[0]?.use.baseURL ?? process.env.E2E_BASE_URL ?? "http://localhost:3000"; + // The shared jar every authenticated spec reads. + await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH); + + // v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant + // signed in at once, and the login endpoint is IP-rate-limited, so the owner + // is logged in here rather than inside the spec. + await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH); + + // The delegate's own jar for that journey. Same account as the shared one, + // deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for + // what happens when the journey switches the shared row instead. + await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH); +} + +/** + * Log one account in and write its cookie jar to `path`. + * + * Each call mints a fresh session row, so two jars for the same account are + * two independent browser sessions — which is the whole point of the delegate + * jar above. + * + * Only the cookies the auth shell needs survive the filter. v1.4.22 C4 added + * `hl_onboarding`: the login route sets it to "pending" for a new account and + * DELETES it for an already-onboarded one, so for these pre-seeded accounts it + * is absent and the proxy passes the dashboard through. + * + * v1.27.11 — the locale cookie is pinned to English rather than inherited. The + * server honours `User.locale` when the cookie is absent (the ITP fix), and + * the locale-switch spec mirrors its German pick into the SHARED account's + * profile — without this cookie every spec running after it would render + * German and the English-string assertions would fail. The cookie sits at the + * top of the resolution ladder, so each spec context stays deterministically + * English; locale-switch overrides it inside its own context only. + */ +async function captureAuthState( + baseURL: string, + account: { username: string; password: string }, + path: string, +): Promise { const ctx = await playwrightRequest.newContext({ baseURL }); - const res = await ctx.post("/api/auth/login", { - data: { - email: E2E_USER.username, - password: E2E_USER.password, - }, - }); - if (res.status() !== 200) { - const body = await res.text(); - throw new Error( - `[e2e/global-setup] login failed: HTTP ${res.status()} — ${body.slice(0, 200)}`, + try { + const res = await ctx.post("/api/auth/login", { + data: { email: account.username, password: account.password }, + }); + if (res.status() !== 200) { + const body = await res.text(); + throw new Error( + `[e2e/global-setup] login failed for ${account.username}: HTTP ${res.status()} — ${body.slice(0, 200)}`, + ); + } + const state = await ctx.storageState(); + state.cookies = state.cookies.filter((c) => + ["healthlog_session", "hl_onboarding"].includes(c.name), ); + state.cookies.push({ + name: "healthlog-locale", + value: "en", + domain: new URL(baseURL).hostname, + path: "/", + expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, + httpOnly: false, + secure: false, + sameSite: "Lax", + }); + await writeFile(path, JSON.stringify(state, null, 2)); + console.log(`[e2e/global-setup] auth state captured to ${path}`); + } finally { + await ctx.dispose(); } - const state = await ctx.storageState(); - // Strip any cookies we don't recognise so storageState only carries - // what the auth shell needs. v1.4.22 C4 added `hl_onboarding` — the - // login route sets it to "pending" for new users and DELETES it for - // already-onboarded ones, so for our pre-seeded completed e2e user - // the cookie is absent and the proxy passes the dashboard through. - state.cookies = state.cookies.filter((c) => - ["healthlog_session", "healthlog-locale", "hl_onboarding"].includes(c.name), - ); - // v1.27.11 — pin the shared auth state to English via an explicit locale - // cookie. The server now honours `User.locale` when the cookie is absent - // (the ITP fix), and the locale-switch spec mirrors its German pick into - // the SHARED e2e user's profile — without this cookie every spec running - // after it would render German and the English-string assertions fail. - // The cookie sits at the top of the resolution ladder, so each spec - // context stays deterministically English; locale-switch overrides it - // inside its own context only. - const base = new URL(baseURL); - state.cookies = state.cookies.filter((c) => c.name !== "healthlog-locale"); - state.cookies.push({ - name: "healthlog-locale", - value: "en", - domain: base.hostname, - path: "/", - expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, - httpOnly: false, - secure: false, - sameSite: "Lax", - }); - await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2)); - await ctx.dispose(); - console.log( - `[e2e/global-setup] auth state captured to ${STORAGE_STATE_PATH}`, - ); } diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index d6af56eb8..9ae2f7a18 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -6,4 +6,12 @@ * specific to the spec-side (e.g. multi-user scenarios that need * fresh logins) have a natural home. */ -export { E2E_USER, STORAGE_STATE_PATH } from "./global-setup"; +export { + E2E_USER, + E2E_OWNER, + E2E_DELEGATE_MARKER_KG, + E2E_OWNER_MARKER_KG, + STORAGE_STATE_PATH, + OWNER_STORAGE_STATE_PATH, + DELEGATE_STORAGE_STATE_PATH, +} from "./global-setup"; diff --git a/messages/de.json b/messages/de.json index 1d8f1da72..cf0d5543b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4955,6 +4955,10 @@ "dismiss": "Verstanden", "dismissError": "Konnte nicht gespeichert werden. Bitte versuche es erneut." } + }, + "access": { + "title": "Geteilter Zugriff", + "subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst." } }, "profile": "Profil", @@ -9373,5 +9377,99 @@ "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", "scopeCount": "{count} Einträge aus {groups} Bereichen", "scopeSensitive": "inkl. {names}" + }, + "recordSharing": { + "banner": { + "viewing": "Du bist in der Akte von {name}", + "readOnly": "Du kannst sie lesen, nicht ändern.", + "leave": "Zurück zu meiner Akte" + }, + "switcher": { + "label": "Akte öffnen", + "ownRecord": "Meine eigene Akte", + "readOnly": "Nur lesen", + "canWrite": "Darf Einträge anlegen" + }, + "switch": { + "failed": "Die Akte konnte nicht geöffnet werden. Bitte noch einmal versuchen.", + "noLongerAvailable": "Diese Akte ist nicht mehr für dich freigegeben." + }, + "unavailable": { + "title": "Gehört nicht zum geteilten Zugriff", + "description": "Geteilt wird die Gesundheitsakte selbst. Einstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.", + "leave": "Zurück zu meiner Akte" + }, + "state": { + "active": "Aktiv", + "pending": "Wartet auf Zustimmung", + "expired": "Abgelaufen", + "revoked": "Beendet" + }, + "row": { + "readOnly": "Darf die Akte lesen", + "canWrite": "Darf lesen und Einträge anlegen", + "invited": "Eingeladen", + "accepted": "Angenommen", + "expires": "Läuft ab", + "lastUsed": "Zuletzt geöffnet", + "neverUsed": "nie", + "endedByYou": "Von dir beendet", + "endedByOwner": "Zurückgezogen", + "handedBack": "Zurückgegeben", + "ended": "Beendet" + }, + "invite": { + "title": "Jemanden einladen", + "description": "Vor der Zustimmung wird nichts geteilt. Bis dahin liegt die Einladung in den Einstellungen der anderen Person und gibt ihr nichts.", + "identifierLabel": "Benutzername oder E-Mail", + "identifierPlaceholder": "Der Name, mit dem sie sich anmeldet", + "identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.", + "submit": "Einladung senden", + "submitting": "Wird gesendet…", + "sent": "Einladung an {name} gesendet.", + "errorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen.", + "errorDuplicate": "Dieses Konto hat bereits Zugriff auf deine Akte.", + "errorSelf": "Das ist dein eigenes Konto.", + "errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.", + "errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.", + "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen." + }, + "given": { + "title": "Wer meine Akte öffnen kann", + "description": "Beendeter Zugriff bleibt in der Liste, damit du immer siehst, wer ihn hatte und wann.", + "emptyTitle": "Niemand sonst kann deine Akte öffnen", + "emptyDescription": "Lade oben jemanden ein, wenn diese Person sie lesen können soll.", + "loadError": "Die Liste konnte nicht geladen werden.", + "revoke": "Zugriff beenden", + "withdrawInvite": "Zurückziehen", + "revokeTitle": "Zugriff von {name} beenden?", + "revokeBody": "Der Zugriff endet sofort, auch in jedem Browser, in dem deine Akte gerade offen ist. Was bereits gesehen wurde, lässt sich nicht zurückholen. Du kannst jederzeit erneut einladen.", + "revokeConfirm": "Zugriff beenden" + }, + "received": { + "title": "Akten, die ich öffnen kann", + "description": "Eine Einladung gibt dir nichts, bevor du sie annimmst. Du kannst den Zugriff jederzeit zurückgeben.", + "emptyTitle": "Niemand hat eine Akte mit dir geteilt", + "emptyDescription": "Wenn dich jemand einlädt, erscheint die Einladung hier.", + "loadError": "Die Liste konnte nicht geladen werden.", + "accept": "Annehmen", + "accepting": "Wird angenommen…", + "open": "Öffnen", + "renounce": "Zurückgeben", + "renounceTitle": "Akte von {name} zurückgeben?", + "renounceBody": "Dein Zugriff endet sofort. Die Person kann dich jederzeit erneut einladen.", + "renounceConfirm": "Zurückgeben" + }, + "activity": { + "title": "Wann meine Akte geöffnet wurde", + "description": "Jedes Mal, wenn jemand anderes deine Akte geöffnet hat.", + "retention": "Das umfasst die letzten {days} Tage. Ältere Einträge werden gelöscht.", + "emptyTitle": "Niemand hat deine Akte geöffnet", + "emptyDescription": "Zugriffe von anderen erscheinen hier, eine Zeile pro Person und Tag.", + "loadError": "Die Aktivität konnte nicht geladen werden.", + "opened": "{name} hat deine Akte {count}× geöffnet", + "acted": "{name} hat etwas in deiner Akte geändert", + "deletedAccount": "Ein gelöschtes Konto" + } } } diff --git a/messages/en.json b/messages/en.json index b44d7e40b..277ac0349 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4955,6 +4955,10 @@ "dismiss": "Got it", "dismissError": "Couldn't save that. Please try again." } + }, + "access": { + "title": "Shared access", + "subtitle": "Who can open your health record, and whose you can open." } }, "profile": "Profile", @@ -9373,5 +9377,99 @@ "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.", "scopeCount": "{count} entries from {groups} areas", "scopeSensitive": "incl. {names}" + }, + "recordSharing": { + "banner": { + "viewing": "You are in {name}'s record", + "readOnly": "You can read it, not change it.", + "leave": "Back to my record" + }, + "switcher": { + "label": "Open a record", + "ownRecord": "My own record", + "readOnly": "Read only", + "canWrite": "Can add entries" + }, + "switch": { + "failed": "Could not open that record. Please try again.", + "noLongerAvailable": "That record is no longer shared with you." + }, + "unavailable": { + "title": "Not part of shared access", + "description": "Sharing covers the health record itself. Settings, connected services, notifications and the AI features stay with the person who owns the account.", + "leave": "Back to my record" + }, + "state": { + "active": "Active", + "pending": "Waiting for acceptance", + "expired": "Lapsed", + "revoked": "Ended" + }, + "row": { + "readOnly": "Can read the record", + "canWrite": "Can read and add entries", + "invited": "Invited", + "accepted": "Accepted", + "expires": "Lapses", + "lastUsed": "Last opened", + "neverUsed": "never", + "endedByYou": "You ended it", + "endedByOwner": "Withdrawn", + "handedBack": "Handed back", + "ended": "Ended" + }, + "invite": { + "title": "Invite someone", + "description": "Nothing is shared until they accept. Until then the invitation sits in their settings and gives them nothing.", + "identifierLabel": "Username or e-mail", + "identifierPlaceholder": "The name they sign in with", + "identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.", + "submit": "Send invitation", + "submitting": "Sending…", + "sent": "Invitation sent to {name}.", + "errorNoAccount": "No account on this instance carries that name.", + "errorDuplicate": "That account already has access to your record.", + "errorSelf": "That is your own account.", + "errorInvalid": "Please enter a username or e-mail address.", + "errorRateLimited": "Too many invitations. Please try again later.", + "errorFailed": "The invitation could not be sent. Please try again." + }, + "given": { + "title": "Who can open my record", + "description": "Ended access stays listed, so you can always see who had it and when.", + "emptyTitle": "Nobody else can open your record", + "emptyDescription": "Invite someone above if you want them to be able to read it.", + "loadError": "The list could not be loaded.", + "revoke": "End access", + "withdrawInvite": "Withdraw", + "revokeTitle": "End {name}'s access?", + "revokeBody": "They lose access immediately, including any browser they have your record open in. What they have already seen cannot be taken back. You can invite them again at any time.", + "revokeConfirm": "End access" + }, + "received": { + "title": "Records I can open", + "description": "An invitation gives you nothing until you accept it. You can hand access back whenever you want.", + "emptyTitle": "Nobody has shared a record with you", + "emptyDescription": "When someone invites you, the invitation appears here.", + "loadError": "The list could not be loaded.", + "accept": "Accept", + "accepting": "Accepting…", + "open": "Open", + "renounce": "Hand back", + "renounceTitle": "Hand {name}'s record back?", + "renounceBody": "You lose access immediately. They can invite you again at any time.", + "renounceConfirm": "Hand back" + }, + "activity": { + "title": "When my record was opened", + "description": "Every time someone else opened your record.", + "retention": "This covers the last {days} days. Older entries are deleted.", + "emptyTitle": "Nobody has opened your record", + "emptyDescription": "Openings by anyone other than you appear here, one line per person per day.", + "loadError": "The activity could not be loaded.", + "opened": "{name} opened your record {count}×", + "acted": "{name} made a change in your record", + "deletedAccount": "A deleted account" + } } } diff --git a/messages/es.json b/messages/es.json index b3f0f4338..e8b81c7b3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4955,6 +4955,10 @@ "dismiss": "Entendido", "dismissError": "No se pudo guardar. Inténtalo de nuevo." } + }, + "access": { + "title": "Acceso compartido", + "subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú." } }, "profile": "Perfil", @@ -9373,5 +9377,99 @@ "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", "scopeCount": "{count} entradas de {groups} áreas", "scopeSensitive": "incl. {names}" + }, + "recordSharing": { + "banner": { + "viewing": "Estás en el historial de {name}", + "readOnly": "Puedes leerlo, no modificarlo.", + "leave": "Volver a mi historial" + }, + "switcher": { + "label": "Abrir un historial", + "ownRecord": "Mi propio historial", + "readOnly": "Solo lectura", + "canWrite": "Puede añadir entradas" + }, + "switch": { + "failed": "No se pudo abrir ese historial. Inténtalo de nuevo.", + "noLongerAvailable": "Ese historial ya no está compartido contigo." + }, + "unavailable": { + "title": "No forma parte del acceso compartido", + "description": "Se comparte el historial de salud en sí. Los ajustes, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.", + "leave": "Volver a mi historial" + }, + "state": { + "active": "Activo", + "pending": "Pendiente de aceptación", + "expired": "Caducado", + "revoked": "Finalizado" + }, + "row": { + "readOnly": "Puede leer el historial", + "canWrite": "Puede leer y añadir entradas", + "invited": "Invitado", + "accepted": "Aceptado", + "expires": "Caduca", + "lastUsed": "Última apertura", + "neverUsed": "nunca", + "endedByYou": "Lo finalizaste tú", + "endedByOwner": "Retirado", + "handedBack": "Devuelto", + "ended": "Finalizado" + }, + "invite": { + "title": "Invitar a alguien", + "description": "No se comparte nada hasta que acepte. Hasta entonces la invitación está en sus ajustes y no le da nada.", + "identifierLabel": "Usuario o correo electrónico", + "identifierPlaceholder": "El nombre con el que inicia sesión", + "identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.", + "submit": "Enviar invitación", + "submitting": "Enviando…", + "sent": "Invitación enviada a {name}.", + "errorNoAccount": "No hay ninguna cuenta con ese nombre en esta instancia.", + "errorDuplicate": "Esa cuenta ya tiene acceso a tu historial.", + "errorSelf": "Esa es tu propia cuenta.", + "errorInvalid": "Introduce un usuario o una dirección de correo.", + "errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.", + "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo." + }, + "given": { + "title": "Quién puede abrir mi historial", + "description": "El acceso finalizado sigue en la lista, para que siempre veas quién lo tuvo y cuándo.", + "emptyTitle": "Nadie más puede abrir tu historial", + "emptyDescription": "Invita a alguien arriba si quieres que pueda leerlo.", + "loadError": "No se pudo cargar la lista.", + "revoke": "Finalizar acceso", + "withdrawInvite": "Retirar", + "revokeTitle": "¿Finalizar el acceso de {name}?", + "revokeBody": "Pierde el acceso de inmediato, incluido cualquier navegador donde tenga tu historial abierto. Lo que ya ha visto no se puede recuperar. Puedes volver a invitarle cuando quieras.", + "revokeConfirm": "Finalizar acceso" + }, + "received": { + "title": "Historiales que puedo abrir", + "description": "Una invitación no te da nada hasta que la aceptas. Puedes devolver el acceso cuando quieras.", + "emptyTitle": "Nadie ha compartido un historial contigo", + "emptyDescription": "Cuando alguien te invite, la invitación aparecerá aquí.", + "loadError": "No se pudo cargar la lista.", + "accept": "Aceptar", + "accepting": "Aceptando…", + "open": "Abrir", + "renounce": "Devolver", + "renounceTitle": "¿Devolver el historial de {name}?", + "renounceBody": "Pierdes el acceso de inmediato. Esa persona puede volver a invitarte cuando quiera.", + "renounceConfirm": "Devolver" + }, + "activity": { + "title": "Cuándo se abrió mi historial", + "description": "Cada vez que otra persona abrió tu historial.", + "retention": "Esto abarca los últimos {days} días. Las entradas más antiguas se eliminan.", + "emptyTitle": "Nadie ha abierto tu historial", + "emptyDescription": "Las aperturas de otras personas aparecen aquí, una línea por persona y día.", + "loadError": "No se pudo cargar la actividad.", + "opened": "{name} abrió tu historial {count}×", + "acted": "{name} hizo un cambio en tu historial", + "deletedAccount": "Una cuenta eliminada" + } } } diff --git a/messages/fr.json b/messages/fr.json index 160b2706a..3f24e137c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4955,6 +4955,10 @@ "dismiss": "Compris", "dismissError": "Enregistrement impossible. Veuillez réessayer." } + }, + "access": { + "title": "Accès partagé", + "subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir." } }, "profile": "Profil", @@ -9373,5 +9377,99 @@ "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.", "scopeCount": "{count} éléments issus de {groups} domaines", "scopeSensitive": "dont {names}" + }, + "recordSharing": { + "banner": { + "viewing": "Vous êtes dans le dossier de {name}", + "readOnly": "Vous pouvez le lire, pas le modifier.", + "leave": "Revenir à mon dossier" + }, + "switcher": { + "label": "Ouvrir un dossier", + "ownRecord": "Mon propre dossier", + "readOnly": "Lecture seule", + "canWrite": "Peut ajouter des entrées" + }, + "switch": { + "failed": "Impossible d'ouvrir ce dossier. Veuillez réessayer.", + "noLongerAvailable": "Ce dossier n'est plus partagé avec vous." + }, + "unavailable": { + "title": "Ne fait pas partie de l'accès partagé", + "description": "Le partage porte sur le dossier de santé lui-même. Les réglages, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.", + "leave": "Revenir à mon dossier" + }, + "state": { + "active": "Actif", + "pending": "En attente d'acceptation", + "expired": "Expiré", + "revoked": "Terminé" + }, + "row": { + "readOnly": "Peut lire le dossier", + "canWrite": "Peut lire et ajouter des entrées", + "invited": "Invité le", + "accepted": "Accepté le", + "expires": "Expire le", + "lastUsed": "Dernière ouverture", + "neverUsed": "jamais", + "endedByYou": "Vous y avez mis fin", + "endedByOwner": "Retiré", + "handedBack": "Rendu", + "ended": "Terminé" + }, + "invite": { + "title": "Inviter quelqu'un", + "description": "Rien n'est partagé avant son acceptation. D'ici là, l'invitation reste dans ses réglages et ne lui donne rien.", + "identifierLabel": "Nom d'utilisateur ou e-mail", + "identifierPlaceholder": "Le nom avec lequel elle se connecte", + "identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.", + "submit": "Envoyer l'invitation", + "submitting": "Envoi…", + "sent": "Invitation envoyée à {name}.", + "errorNoAccount": "Aucun compte de cette instance ne porte ce nom.", + "errorDuplicate": "Ce compte a déjà accès à votre dossier.", + "errorSelf": "C'est votre propre compte.", + "errorInvalid": "Saisissez un nom d'utilisateur ou une adresse e-mail.", + "errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.", + "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer." + }, + "given": { + "title": "Qui peut ouvrir mon dossier", + "description": "Les accès terminés restent dans la liste, pour que vous voyiez toujours qui l'a eu et quand.", + "emptyTitle": "Personne d'autre ne peut ouvrir votre dossier", + "emptyDescription": "Invitez quelqu'un ci-dessus si vous voulez qu'il puisse le lire.", + "loadError": "La liste n'a pas pu être chargée.", + "revoke": "Mettre fin à l'accès", + "withdrawInvite": "Retirer", + "revokeTitle": "Mettre fin à l'accès de {name} ?", + "revokeBody": "L'accès cesse immédiatement, y compris dans tout navigateur où votre dossier est ouvert. Ce qui a déjà été vu ne peut pas être repris. Vous pouvez inviter de nouveau à tout moment.", + "revokeConfirm": "Mettre fin à l'accès" + }, + "received": { + "title": "Dossiers que je peux ouvrir", + "description": "Une invitation ne vous donne rien avant que vous l'acceptiez. Vous pouvez rendre l'accès quand vous voulez.", + "emptyTitle": "Personne n'a partagé de dossier avec vous", + "emptyDescription": "Quand quelqu'un vous invite, l'invitation apparaît ici.", + "loadError": "La liste n'a pas pu être chargée.", + "accept": "Accepter", + "accepting": "Acceptation…", + "open": "Ouvrir", + "renounce": "Rendre", + "renounceTitle": "Rendre le dossier de {name} ?", + "renounceBody": "Vous perdez l'accès immédiatement. Cette personne peut vous inviter de nouveau à tout moment.", + "renounceConfirm": "Rendre" + }, + "activity": { + "title": "Quand mon dossier a été ouvert", + "description": "Chaque fois que quelqu'un d'autre a ouvert votre dossier.", + "retention": "Cela couvre les {days} derniers jours. Les entrées plus anciennes sont supprimées.", + "emptyTitle": "Personne n'a ouvert votre dossier", + "emptyDescription": "Les ouvertures par d'autres apparaissent ici, une ligne par personne et par jour.", + "loadError": "L'activité n'a pas pu être chargée.", + "opened": "{name} a ouvert votre dossier {count}×", + "acted": "{name} a fait une modification dans votre dossier", + "deletedAccount": "Un compte supprimé" + } } } diff --git a/messages/it.json b/messages/it.json index 8713772d7..029d65e4d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4955,6 +4955,10 @@ "dismiss": "Ho capito", "dismissError": "Non è stato possibile salvare. Riprova." } + }, + "access": { + "title": "Accesso condiviso", + "subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu." } }, "profile": "Profilo", @@ -9373,5 +9377,99 @@ "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.", "scopeCount": "{count} voci da {groups} aree", "scopeSensitive": "incl. {names}" + }, + "recordSharing": { + "banner": { + "viewing": "Sei nella cartella di {name}", + "readOnly": "Puoi leggerla, non modificarla.", + "leave": "Torna alla mia cartella" + }, + "switcher": { + "label": "Apri una cartella", + "ownRecord": "La mia cartella", + "readOnly": "Sola lettura", + "canWrite": "Può aggiungere voci" + }, + "switch": { + "failed": "Non è stato possibile aprire quella cartella. Riprova.", + "noLongerAvailable": "Quella cartella non è più condivisa con te." + }, + "unavailable": { + "title": "Non fa parte dell'accesso condiviso", + "description": "La condivisione riguarda la cartella sanitaria in sé. Impostazioni, servizi collegati, notifiche e funzioni di IA restano alla persona proprietaria dell'account.", + "leave": "Torna alla mia cartella" + }, + "state": { + "active": "Attivo", + "pending": "In attesa di accettazione", + "expired": "Scaduto", + "revoked": "Terminato" + }, + "row": { + "readOnly": "Può leggere la cartella", + "canWrite": "Può leggere e aggiungere voci", + "invited": "Invitato il", + "accepted": "Accettato il", + "expires": "Scade il", + "lastUsed": "Ultima apertura", + "neverUsed": "mai", + "endedByYou": "L'hai terminato tu", + "endedByOwner": "Revocato", + "handedBack": "Restituito", + "ended": "Terminato" + }, + "invite": { + "title": "Invita qualcuno", + "description": "Nulla viene condiviso finché non accetta. Fino ad allora l'invito resta nelle sue impostazioni e non le dà nulla.", + "identifierLabel": "Nome utente o e-mail", + "identifierPlaceholder": "Il nome con cui accede", + "identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.", + "submit": "Invia invito", + "submitting": "Invio…", + "sent": "Invito inviato a {name}.", + "errorNoAccount": "Nessun account su questa istanza ha quel nome.", + "errorDuplicate": "Quell'account ha già accesso alla tua cartella.", + "errorSelf": "Questo è il tuo account.", + "errorInvalid": "Inserisci un nome utente o un indirizzo e-mail.", + "errorRateLimited": "Troppi inviti. Riprova più tardi.", + "errorFailed": "Non è stato possibile inviare l'invito. Riprova." + }, + "given": { + "title": "Chi può aprire la mia cartella", + "description": "Gli accessi terminati restano in elenco, così vedi sempre chi li ha avuti e quando.", + "emptyTitle": "Nessun altro può aprire la tua cartella", + "emptyDescription": "Invita qualcuno qui sopra se vuoi che possa leggerla.", + "loadError": "Non è stato possibile caricare l'elenco.", + "revoke": "Termina accesso", + "withdrawInvite": "Ritira", + "revokeTitle": "Terminare l'accesso di {name}?", + "revokeBody": "L'accesso finisce subito, anche in qualsiasi browser in cui la tua cartella è aperta. Ciò che è già stato visto non può essere ritirato. Puoi invitare di nuovo quando vuoi.", + "revokeConfirm": "Termina accesso" + }, + "received": { + "title": "Cartelle che posso aprire", + "description": "Un invito non ti dà nulla finché non lo accetti. Puoi restituire l'accesso quando vuoi.", + "emptyTitle": "Nessuno ha condiviso una cartella con te", + "emptyDescription": "Quando qualcuno ti invita, l'invito compare qui.", + "loadError": "Non è stato possibile caricare l'elenco.", + "accept": "Accetta", + "accepting": "Accettazione…", + "open": "Apri", + "renounce": "Restituisci", + "renounceTitle": "Restituire la cartella di {name}?", + "renounceBody": "Perdi l'accesso subito. Quella persona può invitarti di nuovo quando vuole.", + "renounceConfirm": "Restituisci" + }, + "activity": { + "title": "Quando è stata aperta la mia cartella", + "description": "Ogni volta che qualcun altro ha aperto la tua cartella.", + "retention": "Copre gli ultimi {days} giorni. Le voci più vecchie vengono eliminate.", + "emptyTitle": "Nessuno ha aperto la tua cartella", + "emptyDescription": "Le aperture da parte di altri compaiono qui, una riga per persona e giorno.", + "loadError": "Non è stato possibile caricare l'attività.", + "opened": "{name} ha aperto la tua cartella {count}×", + "acted": "{name} ha fatto una modifica nella tua cartella", + "deletedAccount": "Un account eliminato" + } } } diff --git a/messages/pl.json b/messages/pl.json index 7c9cfb017..3ef29e205 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4955,6 +4955,10 @@ "dismiss": "Rozumiem", "dismissError": "Nie udało się zapisać. Spróbuj ponownie." } + }, + "access": { + "title": "Udostępniony dostęp", + "subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty." } }, "profile": "Profil", @@ -9373,5 +9377,99 @@ "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.", "scopeCount": "{count} pozycji z {groups} obszarów", "scopeSensitive": "w tym {names}" + }, + "recordSharing": { + "banner": { + "viewing": "Jesteś w karcie użytkownika {name}", + "readOnly": "Możesz ją czytać, nie zmieniać.", + "leave": "Wróć do mojej karty" + }, + "switcher": { + "label": "Otwórz kartę", + "ownRecord": "Moja własna karta", + "readOnly": "Tylko odczyt", + "canWrite": "Może dodawać wpisy" + }, + "switch": { + "failed": "Nie udało się otworzyć tej karty. Spróbuj ponownie.", + "noLongerAvailable": "Ta karta nie jest już dla ciebie udostępniona." + }, + "unavailable": { + "title": "Nie należy do udostępnionego dostępu", + "description": "Udostępniana jest sama karta zdrowia. Ustawienia, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.", + "leave": "Wróć do mojej karty" + }, + "state": { + "active": "Aktywny", + "pending": "Czeka na akceptację", + "expired": "Wygasł", + "revoked": "Zakończony" + }, + "row": { + "readOnly": "Może czytać kartę", + "canWrite": "Może czytać i dodawać wpisy", + "invited": "Zaproszono", + "accepted": "Zaakceptowano", + "expires": "Wygasa", + "lastUsed": "Ostatnio otwarto", + "neverUsed": "nigdy", + "endedByYou": "Zakończone przez ciebie", + "endedByOwner": "Wycofane", + "handedBack": "Zwrócone", + "ended": "Zakończone" + }, + "invite": { + "title": "Zaproś kogoś", + "description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje. Do tego czasu zaproszenie leży w jej ustawieniach i nic jej nie daje.", + "identifierLabel": "Nazwa użytkownika lub e-mail", + "identifierPlaceholder": "Nazwa, którą się loguje", + "identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.", + "submit": "Wyślij zaproszenie", + "submitting": "Wysyłanie…", + "sent": "Zaproszenie wysłane do {name}.", + "errorNoAccount": "Na tej instancji nie ma konta o takiej nazwie.", + "errorDuplicate": "To konto ma już dostęp do twojej karty.", + "errorSelf": "To twoje własne konto.", + "errorInvalid": "Podaj nazwę użytkownika lub adres e-mail.", + "errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.", + "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie." + }, + "given": { + "title": "Kto może otworzyć moją kartę", + "description": "Zakończony dostęp zostaje na liście, żebyś zawsze widział, kto go miał i kiedy.", + "emptyTitle": "Nikt inny nie może otworzyć twojej karty", + "emptyDescription": "Zaproś kogoś powyżej, jeśli chcesz, żeby mógł ją czytać.", + "loadError": "Nie udało się wczytać listy.", + "revoke": "Zakończ dostęp", + "withdrawInvite": "Wycofaj", + "revokeTitle": "Zakończyć dostęp dla {name}?", + "revokeBody": "Dostęp kończy się natychmiast, także w każdej przeglądarce, w której twoja karta jest otwarta. Tego, co już zostało zobaczone, nie da się cofnąć. Możesz zaprosić ponownie w dowolnej chwili.", + "revokeConfirm": "Zakończ dostęp" + }, + "received": { + "title": "Karty, które mogę otworzyć", + "description": "Zaproszenie nic ci nie daje, dopóki go nie zaakceptujesz. Dostęp możesz zwrócić w dowolnej chwili.", + "emptyTitle": "Nikt nie udostępnił ci karty", + "emptyDescription": "Kiedy ktoś cię zaprosi, zaproszenie pojawi się tutaj.", + "loadError": "Nie udało się wczytać listy.", + "accept": "Akceptuj", + "accepting": "Akceptowanie…", + "open": "Otwórz", + "renounce": "Zwróć", + "renounceTitle": "Zwrócić kartę użytkownika {name}?", + "renounceBody": "Tracisz dostęp natychmiast. Ta osoba może zaprosić cię ponownie w dowolnej chwili.", + "renounceConfirm": "Zwróć" + }, + "activity": { + "title": "Kiedy otwierano moją kartę", + "description": "Za każdym razem, gdy ktoś inny otworzył twoją kartę.", + "retention": "Obejmuje ostatnie {days} dni. Starsze wpisy są usuwane.", + "emptyTitle": "Nikt nie otworzył twojej karty", + "emptyDescription": "Otwarcia przez inne osoby pojawiają się tutaj, jedna linia na osobę i dzień.", + "loadError": "Nie udało się wczytać aktywności.", + "opened": "{name} otworzył twoją kartę {count}×", + "acted": "{name} wprowadził zmianę w twojej karcie", + "deletedAccount": "Usunięte konto" + } } } diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 7081f57cc..1b01ae55f 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -81,6 +81,16 @@ describe("the acting-account carrier is read in one place", () => { // themselves call these two functions and never name the column, which is // what keeps this list from growing one entry per surface. "lib/sharing/acting-session.ts", + // v1.36.0 — the only thing that PUBLISHES it, and it publishes the stamp + // only after re-deciding it. The account payload has to say which record + // the browser is inside so the banner can name a person, but the value it + // prints is not the column: an entry reaches `active` solely by surviving + // the same live-grant pass that builds the switcher list, so a stamp left + // behind by a lapsed grant reads as "not switched" — the same answer the + // resolver will independently give the next delegated request. Takes the + // whole auth context for exactly this reason: had it taken the id, the + // route would name the column too, and the list would grow per surface. + "lib/sharing/account-access.ts", ].sort(); it("no other file reads or writes the carrier column", () => { diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 526090ab1..ea33dbde6 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -252,13 +252,15 @@ const DELEGABLE_ROUTES: Record = {}; * * The intended members are the surfaces a switched session needs in order to * stay usable and to get back out: the account bootstrap payload, the switch - * endpoint, logout, the native refresh route, the locale read. One of them - * exists so far. The rest arrive as their own diffs here; naming them before + * endpoint, logout, the native refresh route, the locale read. Two of them + * exist so far. The rest arrive as their own diffs here; naming them before * they exist would freeze a guess. */ const ACTOR_ROUTES: Record = { "app/api/account/switch/route.ts": "The way back out. Every other route refuses under a switch, so if this one did too a browser could enter a record and never leave it. It reads and writes exactly one row — the caller's own session — renders nothing of the owner's, and grants nothing: the account it stamps is validated against a live grant first, and the stamp is re-checked on every request after.", + "app/api/auth/me/route.ts": + "The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.", }; /** @@ -266,7 +268,7 @@ const ACTOR_ROUTES: Record = { * a formality by accident: every addition has to be counted here as well as * listed above, which is one more place a careless admission has to pass. */ -const FROZEN_ENTRY_COUNT = 1; +const FROZEN_ENTRY_COUNT = 2; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index bbface639..952a5b7b1 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -500,6 +500,40 @@ const REGISTRY: DestructiveEntry[] = [ ], }, + // ── Account sharing ───────────────────────────────────────────────────── + { + file: "components/settings/access/grants-given-card.tsx", + destroys: + "another account's standing access to this health record, and every browser session of theirs that was inside it", + recovery: "permanent", + confirm: ["ConfirmButton"], + undetected: + "No detector sees it. The DELETE goes out from `useRevokeGrant` in `lib/queries/use-account-grants.ts`, and `lib/` is an excluded root; the card itself only calls `.mutate(grant.id)` with a bare string, which no payload pattern matches, and the route path lives in the hook rather than here. Registered by hand.", + }, + { + file: "components/settings/access/grants-received-card.tsx", + destroys: + "this account's own access to somebody else's record, handed back rather than withdrawn", + recovery: "permanent", + confirm: ["ConfirmButton"], + undetected: + "Same shape as the card above, and one step further out of reach: renouncing is a POST, so not even a DELETE goes out. The path (`/api/account/grants/{id}/renounce`) names an act the D2 vocabulary does not carry, and it lives in the hook regardless. Registered by hand.", + }, + { + file: "components/settings/access/grant-invite-card.tsx", + destroys: + "nothing — it offers access, and the offer confers nothing until the other person accepts it", + recovery: "permanent", + confirm: [], + unconfirmed: [ + { + control: "the invitation form", + reason: + "D3 flags this file for `expiresAt: null` in the invite payload, reading it as a field being blanked. It is not: null means the grant runs until somebody ends it, which is the common household case and the absence of an expiry rather than the removal of one. The form creates a pending row and destroys nothing, and gating a creation behind a confirmation dialog would teach people to click through the dialogs that do guard a loss.", + }, + ], + }, + // ── Admin ─────────────────────────────────────────────────────────────── { file: "components/admin/danger-zone-section.tsx", diff --git a/src/__tests__/e2e-shared-session-guard.test.ts b/src/__tests__/e2e-shared-session-guard.test.ts new file mode 100644 index 000000000..037c8980e --- /dev/null +++ b/src/__tests__/e2e-shared-session-guard.test.ts @@ -0,0 +1,95 @@ +/** + * Structural guard on the E2E suite's session boundary. + * + * Account switching is stamped on the session row, not held in the tab. Every + * Playwright context opened from `STORAGE_STATE_PATH` carries the SAME session + * cookie, so a spec that switches that jar into another account's record + * switches it for every spec authenticating with it — and the suite runs + * `fullyParallel`. The failure lands in an unrelated file: a settings surface + * painting "Not part of shared access", an axe scan reading a banner its spec + * never asked for, a `waitForResponse` on a request the refused surface never + * sends. Nothing in the spec that fails is wrong, and nothing in its file + * explains it. + * + * So: a spec that switches accounts uses its own jar. This file holds that + * shape by matching the two things a switching spec cannot avoid naming — the + * menu entry it clicks and the endpoint that entry calls. + * + * It is a tripwire, not a proof. A future spec could switch through a control + * neither marker names, and this guard would not see it; the counter-check + * below is what stops the matchers from quietly matching nothing. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, globSync } from "node:fs"; +import { join, sep } from "node:path"; + +const E2E = join(process.cwd(), "e2e"); + +/** + * Markers that mean "this spec drives an account switch": the switcher entry + * a person clicks, and the endpoint that click posts to. + */ +const SWITCH_MARKERS = ["account-switcher-entry", "/api/account/switch"]; + +/** The jar shared by every ordinary authenticated spec. */ +const SHARED_JAR = "STORAGE_STATE_PATH"; + +function specFiles(): string[] { + return globSync("*.spec.ts", { cwd: E2E }) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(E2E, rel), "utf8"); +} + +/** + * The file with comments removed. This guard's own subject matter is worth + * explaining in prose inside the specs, and a naive substring match would be + * satisfied by that prose alone. + */ +function stripComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +function switchingSpecs(): string[] { + return specFiles().filter((rel) => { + const src = stripComments(read(rel)); + return SWITCH_MARKERS.some((marker) => src.includes(marker)); + }); +} + +/** + * `STORAGE_STATE_PATH` as a whole word, so `OWNER_STORAGE_STATE_PATH` and + * `DELEGATE_STORAGE_STATE_PATH` — the isolated jars, which are the fix rather + * than the problem — do not read as a hit. + */ +function usesSharedJar(src: string): boolean { + return new RegExp(`(^|[^A-Z_])${SHARED_JAR}\\b`).test(src); +} + +describe("e2e specs that switch accounts do not share a session", () => { + it("finds the specs that switch at all", () => { + // An empty match set would make every assertion below vacuous. The suite + // has a switching journey; if it ever has none, this guard has no subject + // and should be deleted rather than left passing. + expect(switchingSpecs().length).toBeGreaterThan(0); + }); + + it("none of them authenticates with the shared jar", () => { + const offenders = switchingSpecs().filter((rel) => + usesSharedJar(stripComments(read(rel))), + ); + expect(offenders).toEqual([]); + }); + + it("the shared jar is still what the ordinary specs read", () => { + // The other way this guard could go quietly green: the shared jar renamed + // or retired, leaving the matcher above looking for a string nothing uses. + const sharing = specFiles().filter((rel) => + usesSharedJar(stripComments(read(rel))), + ); + expect(sharing.length).toBeGreaterThan(10); + }); +}); diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts new file mode 100644 index 000000000..993cf473a --- /dev/null +++ b/src/app/api/account/activity/route.ts @@ -0,0 +1,128 @@ +/** + * `GET /api/account/activity` — who opened this account's record, and when. + * + * The owner's side of the audit actor. Every row filed under this account that + * somebody ELSE performed, newest first: the day-coalesced read rows the + * delegated resolver writes, and any delegated action that lands an audit row + * of its own. Rows the owner performed themselves are absent by construction — + * `actorUserId` is null for those, and this feed is not a second copy of the + * security-activity feed. + * + * **The window is published, not implied.** `retentionDays` rides the response + * because audit rows are purged on a schedule the operator configures + * (`AUDIT_LOG_RETENTION_DAYS`, 365 by default) and a view that showed "the + * activity" without saying how far back it goes would claim a completeness it + * does not have. The number is resolved server-side from the same function the + * purge job uses, so the sentence in the UI cannot drift from the job that + * makes it true — an instance running a 90-day window says 90. + * + * Not delegable: bare `requireAuth()` refuses under a switch. A delegate must + * not be able to read the owner's record of who has been in it — that list + * names other delegates, and a grant is not an introduction to the household. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { DELEGATED_ACCESS_ACTION } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; +import { annotate } from "@/lib/logging/context"; +import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view"; + +export const dynamic = "force-dynamic"; + +/** + * How many rows the feed returns. One row per (delegate, day) for reads, so a + * hundred covers months of a busy household — and a longer list answers a + * question nobody asked while making the recent activity harder to see. + */ +const MAX_ROWS = 100; + +export interface RecordActivityEntry { + id: string; + /** + * Who did it. Null when that account has since been deleted: the id on the + * row is deliberately not a foreign key, so history is not rewritten when a + * person leaves. A row whose actor cannot be resolved says "a deleted + * account", which is true, rather than silently reading as the owner's own. + */ + actor: { id: string; username: string; displayName: string | null } | null; + action: string; + /** + * For a coalesced read row, how many times that delegate opened the record + * that day. Null for anything else — a single act has no count, and a `1` + * there would invite the reader to add up numbers that mean different things. + */ + accesses: number | null; + createdAt: string; +} + +/** The `accesses` counter, when the row is a coalesced read. */ +function readAccessCount( + action: string, + details: string | null, +): number | null { + if (action !== DELEGATED_ACCESS_ACTION || details === null) return null; + try { + const parsed: unknown = JSON.parse(details); + if (typeof parsed !== "object" || parsed === null) return null; + const value = (parsed as { accesses?: unknown }).accesses; + return typeof value === "number" && Number.isFinite(value) ? value : null; + } catch { + // A row whose details cannot be parsed still happened. Dropping the count + // loses a number; dropping the row would lose the access. + return null; + } +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const rows = await prisma.auditLog.findMany({ + where: { + userId: user.id, + actorUserId: { not: null }, + // A switch into one's own record is not a delegation and has nothing to + // report. `actingActorFor` already refuses to stamp one, so this is the + // second end of that rule rather than its only enforcement. + NOT: { actorUserId: user.id }, + }, + orderBy: { createdAt: "desc" }, + take: MAX_ROWS, + select: { + id: true, + actorUserId: true, + action: true, + details: true, + createdAt: true, + }, + }); + + const actorIds = [ + ...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)), + ]; + const actors = await prisma.user.findMany({ + where: { id: { in: actorIds } }, + select: GRANT_PARTY_SELECT, + }); + const byId = new Map(actors.map((a) => [a.id, a])); + + const entries: RecordActivityEntry[] = rows.map((row) => ({ + id: row.id, + actor: row.actorUserId ? (byId.get(row.actorUserId) ?? null) : null, + action: row.action, + accesses: readAccessCount(row.action, row.details), + createdAt: row.createdAt.toISOString(), + })); + + annotate({ + action: { name: "sharing.activity.list" }, + meta: { entry_count: entries.length }, + }); + + return apiSuccess({ + entries, + // The bound the view states out loud. Resolved here so the UI never has to + // hardcode a number the operator can change. + retentionDays: getAuditLogRetentionDays(), + }); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 90643a7b7..ee0d395f5 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,5 +1,23 @@ +/** + * `GET /api/auth/me` — the account payload, and the one surface that keeps + * answering while the browser is acting on somebody else's record. + * + * v1.36.0 made it an ACTOR surface (`requireActorAuth`). It has to be: the + * switcher, the banner naming whose record is open, and the way back out all + * read this payload, and all three are about the person at the keyboard, not + * the record they are looking at. Under a bare `requireAuth()` the whole app + * shell would 403 the moment a switch was on. + * + * So every field below is still the CALLER's own — their preferences, their + * modules, their identity — and the one field about the switch, + * `accountAccess`, says so explicitly. Nothing here ever renders the owner's + * data; a delegate reading their own display preferences while inside + * somebody else's record is the correct behaviour and the reason the mode + * exists (design §4: locale, theme and layout belong to the person, not the + * record). + */ import { apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireActorAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; @@ -12,11 +30,13 @@ import { getOperatorModuleAvailability, } from "@/lib/modules/gate"; import { parseTourProgress } from "@/lib/onboarding/tour-progress"; +import { resolveAccountAccess } from "@/lib/sharing/account-access"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const auth = await requireActorAuth(); + const { user } = auth; annotate({ action: { name: "auth.me" } }); @@ -27,33 +47,38 @@ export const GET = apiHandler(async () => { // cycle-tracking gate; no row is forced, a NULL toggle derives from // gender) is a Postgres round-trip. Running them sequentially added // the cookie hop to every /me — and /me sits on every app boot path. - const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([ - setOnboardingPendingCookie(user.onboardingCompletedAt == null), - // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app - // boot, mirroring the onboarding-cookie resync. A locally edited cookie is - // corrected here; an account that enrols (or has the policy lifted) loses - // the redirect on the next /me read. - syncMfaEnrollCookie(user.id, { - totpConfirmedAt: user.totpConfirmedAt, - mfaEnforced: user.mfaEnforced, - }), - prisma.cycleProfile.findUnique({ - where: { userId: user.id }, - select: { cycleTrackingEnabled: true }, - }), - // v1.18.0 — resolved module enable/disable map for every toggleable - // module. cycle/coach reflect their real delegated state (the cycle - // gate / disableCoach + operator assistant flag); the rest read the - // disabled-allowlist `modulePreferencesJson`. Default-on. Clients - // hide a whole module surface end-to-end when its key is `false`. - resolveModuleMap(user.id), - // v1.18.0 — operator-layer availability map (server-wide kill-switch). - // The resolved `modules` map above already AND-s this in, so it cannot - // distinguish operator-off from user-off. The Modules hub needs that - // distinction to render an operator-disabled module as a read-only - // "disabled server-wide" row instead of a live toggle that no-ops. - getOperatorModuleAvailability(), - ]); + const [, , cycleProfile, modules, moduleAvailability, accountAccess] = + await Promise.all([ + setOnboardingPendingCookie(user.onboardingCompletedAt == null), + // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app + // boot, mirroring the onboarding-cookie resync. A locally edited cookie is + // corrected here; an account that enrols (or has the policy lifted) loses + // the redirect on the next /me read. + syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }), + prisma.cycleProfile.findUnique({ + where: { userId: user.id }, + select: { cycleTrackingEnabled: true }, + }), + // v1.18.0 — resolved module enable/disable map for every toggleable + // module. cycle/coach reflect their real delegated state (the cycle + // gate / disableCoach + operator assistant flag); the rest read the + // disabled-allowlist `modulePreferencesJson`. Default-on. Clients + // hide a whole module surface end-to-end when its key is `false`. + resolveModuleMap(user.id), + // v1.18.0 — operator-layer availability map (server-wide kill-switch). + // The resolved `modules` map above already AND-s this in, so it cannot + // distinguish operator-off from user-off. The Modules hub needs that + // distinction to render an operator-disabled module as a read-only + // "disabled server-wide" row instead of a live toggle that no-ops. + getOperatorModuleAvailability(), + // v1.36.0 — which records this caller may open, which one they are + // inside, and what they may do there. Every value resolved server-side; + // see the module docblock for why none of it is left to the client. + resolveAccountAccess(auth), + ]); const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); // v1.7.0 — patient-identity fields for the health-record export. The @@ -142,5 +167,11 @@ export const GET = apiHandler(async () => { // this to show a "disabled server-wide" read-only row; everywhere else // the already-AND-ed `modules` map is the single gate to read. moduleAvailability, + // v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu, + // `active` is the record this session is inside (null when it is in its + // own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds + // directly. Always present — an account with no grants gets an empty list, + // not a missing field. + accountAccess, }); }); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 904500df1..4df2a7c55 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import type { JSX } from "react"; +import { AccessSection } from "@/components/settings/access-section"; import { AccountSection } from "@/components/settings/account-section"; import { AnamnesisSection } from "@/components/settings/anamnesis-section"; import { SecuritySection } from "@/components/settings/security-section"; @@ -60,6 +61,7 @@ const SECTION_COMPONENTS: Record< () => JSX.Element | null > = { account: AccountSection, + access: AccessSection, security: SecuritySection, modules: ModulesSection, about: AboutSection, diff --git a/src/components/layout/__tests__/nav-model-shared-record.test.ts b/src/components/layout/__tests__/nav-model-shared-record.test.ts new file mode 100644 index 000000000..36061a96c --- /dev/null +++ b/src/components/layout/__tests__/nav-model-shared-record.test.ts @@ -0,0 +1,137 @@ +/** + * What the navigation offers inside somebody else's record. + * + * Paint, not enforcement — every route behind a hidden entry refuses on its + * own, from a frozen allowlist this model cannot reach. What these pin is that + * the line is drawn in ONE place: the mobile bar's fixed slots, the sidebar, + * the More hub and the shell's deep-link guard all ask the same list, so they + * cannot end up disagreeing about what a delegate is offered. + * + * The default is the safe one. A destination that says nothing about sharing + * drops out, so a surface added later is hidden until somebody has thought + * about it rather than exposed until somebody notices. + * + * Mutation check, run: flip the default in `isNavDestinationVisible` so an + * unflagged destination survives a switch → "hides everything that has not + * been classified" goes red naming Insights and the Coach. + */ +import { describe, expect, it } from "vitest"; + +import { + BOTTOM_NAV_PRIMARY_SLOT_HREFS, + NAV_DESTINATIONS, + isDestinationInSharedRecord, + mobileMoreHubDestinations, + visibleNavDestinations, + visibleUtilityDestinations, +} from "../nav-model"; + +/** Everything enabled, so the module gate never masks the sharing gate. */ +const ALL_MODULES = Object.fromEntries( + NAV_DESTINATIONS.filter((d) => d.requiresModule).map((d) => [ + d.requiresModule as string, + true, + ]), +); + +function hrefsInSharedRecord(): string[] { + return visibleNavDestinations(ALL_MODULES, true, true).map((d) => d.href); +} + +describe("the destination list under a switch", () => { + it("still offers the health record itself", () => { + const hrefs = hrefsInSharedRecord(); + // The non-zero proof for every negative assertion below: a gate that hid + // everything would satisfy all of them and leave a delegate with a blank + // app. + expect(hrefs.length).toBeGreaterThan(5); + expect(hrefs).toContain("/"); + expect(hrefs).toContain("/measurements"); + expect(hrefs).toContain("/medications"); + expect(hrefs).toContain("/labs"); + }); + + it("hides everything that has not been classified", () => { + const hrefs = hrefsInSharedRecord(); + const unflagged = NAV_DESTINATIONS.filter((d) => !d.sharedRecord).map( + (d) => d.href, + ); + // Non-zero: if every destination were flagged this loop would assert + // nothing at all. + expect(unflagged.length).toBeGreaterThan(0); + for (const href of unflagged) { + expect( + hrefs, + `${href} should not be offered inside a shared record`, + ).not.toContain(href); + } + }); + + it("hides the AI surfaces by name", () => { + // Not incidental. Server-managed LLM egress of a person's health data + // rides the consent THAT person gave for their own use, so a delegate + // triggering it would create a consent-shaped act the owner never made. + const hrefs = hrefsInSharedRecord(); + expect(hrefs).not.toContain("/insights"); + expect(hrefs).not.toContain("/coach"); + }); + + it("changes nothing for a session in its own record", () => { + const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href); + expect(own).toEqual(NAV_DESTINATIONS.map((d) => d.href)); + }); +}); + +describe("the utility tail under a switch", () => { + it("disappears entirely", () => { + // Settings holds credentials, integrations, notification channels and + // grant management; Notifications is the delegate's own device business. + expect(visibleUtilityDestinations({ sharedRecord: true })).toEqual([]); + expect(visibleUtilityDestinations().length).toBeGreaterThan(0); + }); +}); + +describe("the mobile bar under a switch", () => { + it("drops the same entries from the More hub", () => { + const hub = mobileMoreHubDestinations({ + modules: ALL_MODULES, + sharedRecord: true, + }).map((d) => d.href); + expect(hub.length).toBeGreaterThan(0); + expect(hub).not.toContain("/coach"); + expect(hub).toContain("/measurements"); + for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) { + expect(hub).not.toContain(slot); + } + }); + + it("answers for the fixed Insights slot from the same list", () => { + // The bar carries its own literal for the fixed slots. Having it ASK the + // model rather than carry a second answer is what stops the two surfaces + // drifting — which is the whole reason this module exists. + expect(isDestinationInSharedRecord("/insights")).toBe(false); + expect(isDestinationInSharedRecord("/medications")).toBe(true); + }); +}); + +describe("the deep-link guard reads the same list", () => { + it("covers a destination and everything beneath it", () => { + expect(isDestinationInSharedRecord("/measurements")).toBe(true); + expect(isDestinationInSharedRecord("/measurements/abc123")).toBe(true); + expect(isDestinationInSharedRecord("/labs/panel/7")).toBe(true); + }); + + it("matches the dashboard exactly, so it does not swallow the app", () => { + // `"/"` is a destination like any other, and a prefix match on it would + // report every path in the product as covered — including /settings. + expect(isDestinationInSharedRecord("/")).toBe(true); + expect(isDestinationInSharedRecord("/settings/account")).toBe(false); + expect(isDestinationInSharedRecord("/notifications")).toBe(false); + }); + + it("answers false for a path no destination claims", () => { + // A surface nobody has classified is not one to open inside somebody + // else's record. + expect(isDestinationInSharedRecord("/some/new/surface")).toBe(false); + }); +}); diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx new file mode 100644 index 000000000..36c589b56 --- /dev/null +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -0,0 +1,174 @@ +/** + * The chrome that tells somebody whose record they are in. + * + * These render the real components to markup rather than asserting on their + * source, because the property that matters is what a person sees. The suite + * runs SSR-only (`@testing-library/react` is not a dependency here), so what + * is proven is the paint: the banner appears exactly when the server says the + * session is inside a record, it names the person, and the switcher offers + * exactly the accounts the server published. What is NOT proven here is the + * click — the switch itself is driven end to end in + * `e2e/account-sharing.spec.ts`. + * + * Mutation checks, run: + * - make the banner render unconditionally (drop the `if (!active)` bail) → + * "renders nothing in the caller's own record" goes red. + * - make the switcher ignore `canSwitch` → "renders nothing when nothing is + * shared" goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AccountAccess } from "@/lib/sharing/account-access-view"; + +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(), + }), + useLogout: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { AccountSwitcherMenuItems } from "../account-switcher-menu"; +import { SharedRecordBanner } from "../shared-record-banner"; + +const OWNER = { + accountId: "acct-owner", + username: "grandma", + displayName: "Margarethe", + access: "read" as const, + canWrite: false, +}; + +function render(access: AccountAccess, node: React.ReactNode): string { + mockAccessRef.value = access; + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("", () => { + it("renders nothing in the caller's own record", () => { + const html = render( + { accounts: [OWNER], active: null, canSwitch: true }, + , + ); + // Holding a grant is not being inside the record. A banner that appeared + // on the strength of the list would claim a context the session is not in. + expect(html).toBe(""); + }); + + it("names the person whose record is open", () => { + const html = render( + { accounts: [OWNER], active: OWNER, canSwitch: true }, + , + ); + expect(html).toContain('data-slot="shared-record-banner"'); + expect(html).toContain('data-account-id="acct-owner"'); + // The display name, not the username: this is the line somebody reads + // before they log a blood pressure. + expect(html).toContain("Margarethe"); + }); + + it("falls back to the username when the account set no display name", () => { + const html = render( + { + accounts: [{ ...OWNER, displayName: null }], + active: { ...OWNER, displayName: null }, + canSwitch: true, + }, + , + ); + // Never an unnamed banner. "You are in 's record" is worse than useless. + expect(html).toContain("grandma"); + }); + + it("says the access is read-only, and carries the way out", () => { + const html = render( + { accounts: [OWNER], active: OWNER, canSwitch: true }, + , + ); + expect(html).toContain('data-slot="shared-record-banner-exit"'); + expect(html).toContain("read it, not change it"); + }); + + it("drops the read-only line when the server says the grant can write", () => { + // Resolved server-side. The banner never decides what a grant permits; the + // day a WRITE grant exists this line stops claiming otherwise on its own. + const writable = { ...OWNER, access: "write" as const, canWrite: true }; + const html = render( + { accounts: [writable], active: writable, canSwitch: true }, + , + ); + expect(html).toContain('data-slot="shared-record-banner"'); + expect(html).not.toContain("read it, not change it"); + }); + + it("uses the warning register, not a subtle tint", () => { + // Somebody who stops noticing the banner logs their own reading into + // another person's record. The visual register is part of the contract. + const html = render( + { accounts: [OWNER], active: OWNER, canSwitch: true }, + , + ); + expect(html).toContain("bg-warning/15"); + expect(html).toContain("border-warning/40"); + }); +}); + +describe("", () => { + it("renders nothing when nothing is shared", () => { + const html = render( + { accounts: [], active: null, canSwitch: false }, + , + ); + // An account that has never used sharing sees the user menu it always saw. + expect(html).toBe(""); + }); + + it("binds canSwitch rather than the list length", () => { + // The server publishes the boolean; the client renders it. A menu that + // derived the affordance from `accounts.length` would be a second program + // deciding whether this person may switch. + const html = render( + { accounts: [OWNER], active: null, canSwitch: false }, + , + ); + expect(html).toBe(""); + }); + + // WHAT IS NOT PROVEN HERE. Nothing below renders the switcher's account + // rows. Radix mounts `DropdownMenuSubContent` — and the `MenuContent` its + // trigger requires — inside a portal, and portals do not render server-side + // at all, so any assertion about the rows would be measuring the renderer + // rather than the component. An earlier draft asserted on that markup and + // passed for the wrong reason. The rows, their labels, the active tick and + // the click are driven against a real browser in + // `e2e/account-sharing.spec.ts`. + // + // What the two cases above DO prove is the gate, and they prove it sharply: + // with the `canSwitch` bail removed they do not merely return different + // markup, they throw, because the Radix sub-menu has no menu root here. +}); diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx new file mode 100644 index 000000000..e132d385c --- /dev/null +++ b/src/components/layout/account-switcher-menu.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Check, Loader2, Users } from "lucide-react"; + +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; + +/** + * v1.36.0 — the switcher, as a slice of the user menu. + * + * One component mounted in both user menus (the desktop sidebar's avatar + * dropdown and the mobile top bar's) rather than the same block written twice. + * The two menus have drifted before — that is the whole argument behind + * `nav-model.ts` — and a switcher that exists on one surface and not the other + * is a person who can enter a record on their laptop and cannot leave it on + * their phone. + * + * Renders NOTHING unless the server says there is somewhere to go: `canSwitch` + * arrives resolved on `accountAccess` and is bound directly. The list is + * `accounts`, also resolved — the client never looks at grant rows and has none + * to look at. An account that has never been shared with sees a user menu + * identical to the one it saw before this feature existed. + * + * "Your own record" is a first-class entry rather than only a banner action: + * the way out has to be reachable from the same menu as the way in, because + * that is where somebody will look for it. + */ +export function AccountSwitcherMenuItems() { + const { user } = useAuth(); + const { t } = useTranslations(); + const switchAccount = useAccountSwitch(); + + const access = user?.accountAccess; + if (!access?.canSwitch) return null; + + const activeId = access.active?.accountId ?? null; + + return ( + <> + + + + {switchAccount.isPending ? ( + + + switchAccount.mutate(null)} + > + + {t("recordSharing.switcher.ownRecord")} + + {activeId === null && ( + ✓ + )} + + {access.accounts.map((account) => ( + switchAccount.mutate(account.accountId)} + > +
+ {accountLabel(account)} + {/* Meta, so muted: what the grant lets this person do, in the + words the server resolved rather than a level code. */} + + {account.canWrite + ? t("recordSharing.switcher.canWrite") + : t("recordSharing.switcher.readOnly")} + +
+ {activeId === account.accountId && ( + + )} +
+ ))} +
+
+ + ); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 3fa3a14d5..158eb17e1 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -12,9 +12,12 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; +import { isDestinationInSharedRecord } from "./nav-model"; import { BottomNav } from "./bottom-nav"; import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; +import { SharedRecordBanner } from "./shared-record-banner"; +import { SharedRecordUnavailable } from "./shared-record-unavailable"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -69,6 +72,14 @@ export function AuthShell({ pathname.startsWith("/about") || pathname.startsWith("/c/"); const isAdminPage = pathname.startsWith("/admin"); + // v1.36.0 — a page reached while acting on somebody else's record that + // sharing does not cover. The nav already drops these entries, so arriving + // here means a bookmark or a browser back button; the panel below explains + // it instead of letting the page paint a row of 403 error cards. Paint only + // — every route behind these pages refuses on its own. + const outsideSharedRecord = + user?.accountAccess?.active != null && + !isDestinationInSharedRecord(pathname); const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; @@ -301,6 +312,13 @@ export function AuthShell({ the first chrome line a user sees in the offline branch. */} + {/* v1.36.0 — whose record is open. Sits directly under the + connection strip and above everything else: a person who has + forgotten they are switched is about to write a reading into + somebody else's record, and no other line of chrome is worth + seeing first. Renders nothing when the session is in its own + account, which is every session that has never used sharing. */} + {/* Demo instances surface a persistent "changes are not saved" strip — the proxy blocks every mutation, and without the banner that block only surfaced as a raw API @@ -357,7 +375,7 @@ export function AuthShell({ data-slot="main-content-wrapper" className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6" > - {children} + {outsideSharedRecord ? : children} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index e607fad92..3b67fa163 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -14,6 +14,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, + isDestinationInSharedRecord, mobileMoreHubDestinations, } from "@/components/layout/nav-model"; import type { ModuleKey } from "@/lib/modules/registry"; @@ -92,6 +93,10 @@ export function BottomNav() { // module from flickering into the hub / primary slot before the query // resolves (the #418-class divergence); once mounted the real map applies. const mounted = useMounted(); + // v1.36.0 — acting on somebody else's record. Same gate as the sidebar, from + // the same resolved payload, so the two bars cannot disagree about what a + // delegate is offered. + const sharedRecord = user?.accountAccess?.active != null; // 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 @@ -102,8 +107,9 @@ export function BottomNav() { mobileMoreHubDestinations({ modules: user?.modules, mounted, + sharedRecord, }), - [user?.modules, mounted], + [user?.modules, mounted, sharedRecord], ); // v1.18.0 — drop a module-gated primary slot (Insights) when the account @@ -115,10 +121,14 @@ export function BottomNav() { () => PRIMARY_RIGHT.filter( (item) => - !item.requiresModule || - (mounted && user?.modules?.[item.requiresModule] !== false), + // v1.36.0 — the fixed slot asks the shared destination model + // whether sharing covers it, rather than carrying its own answer: + // Insights is an AI surface and drops out under a switch. + (!sharedRecord || isDestinationInSharedRecord(item.href)) && + (!item.requiresModule || + (mounted && user?.modules?.[item.requiresModule] !== false)), ), - [user?.modules, mounted], + [user?.modules, mounted, sharedRecord], ); function isActiveLink(href: string) { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 7dacefed9..f8e8ab0b7 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -57,6 +57,24 @@ export interface NavDestination { * not a re-derivation. */ requiresModule?: ModuleKey; + /** + * v1.36.0 — is this destination part of what account sharing covers. + * + * `false` (the default, so the omission is the safe answer) means the entry + * disappears while the browser is acting on somebody else's record. It is + * PAINT and nothing else: the server refuses every non-delegable route under + * a switch on its own, from a frozen allowlist that this file cannot reach + * and does not mirror. Hiding the entry only spares a delegate a click that + * ends in a refusal — both ends, always, and this is the cosmetic end. + * + * The line the flag draws is the design's (§4, §6): a delegate reads the + * health RECORD, and never the account around it. So the tracking surfaces + * are marked, and everything that configures, connects, exports, or asks an + * AI about the account is not. AI is the one that looks arbitrary and is + * not: server-managed LLM egress of the owner's data rides a consent the + * owner gave for their own use, so those surfaces stay owner-only in v1. + */ + sharedRecord?: boolean; } /** @@ -69,15 +87,23 @@ export interface NavDestination { // previous: Medications → Vorsorge → Labs → Illness → Insights → Coach → // Achievements. A disabled module simply drops out; the rest keep this order. export const NAV_DESTINATIONS: ReadonlyArray = [ - { href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" }, + { + href: "/", + sharedRecord: true, + tKey: "nav.dashboard", + icon: Home, + tourId: "nav-dashboard", + }, { href: "/measurements", + sharedRecord: true, tKey: "nav.measurements", icon: Activity, tourId: "nav-measurements", }, { href: "/mood", + sharedRecord: true, tKey: "nav.mood", icon: Waves, tourId: "nav-mood", @@ -91,6 +117,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // no longer borrows the Insights tab strip / layout shell). { href: "/mental-wellbeing", + sharedRecord: true, tKey: "nav.mentalWellbeing", icon: Brain, tourId: "nav-mental-wellbeing", @@ -98,6 +125,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/cycle", + sharedRecord: true, tKey: "nav.cycle", icon: Droplets, tourId: "nav-cycle", @@ -105,6 +133,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/medications", + sharedRecord: true, tKey: "nav.medications", icon: Pill, tourId: "nav-medications", @@ -119,6 +148,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // Reminders hub still links to it; this is the direct front door. { href: "/checkups", + sharedRecord: true, tKey: "nav.vorsorge", icon: Stethoscope, tourId: "nav-vorsorge", @@ -132,6 +162,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/labs", + sharedRecord: true, tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs", @@ -143,6 +174,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // absent until the account turns the module on from the Modules hub. { href: "/illness", + sharedRecord: true, tKey: "nav.illness", icon: Thermometer, tourId: "nav-illness", @@ -158,11 +190,17 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // until the account turns the module on from the Modules hub. { href: "/documents", + sharedRecord: true, tKey: "nav.documents", icon: FileScan, tourId: "nav-documents", requiresModule: "inboundDocuments", }, + // v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they + // drop out under a switch. Both are AI surfaces, and AI egress of a + // person's health data rides the consent THAT person gave for their own + // use; a delegate triggering it would create a consent-shaped act the owner + // never made. Non-delegable in v1, server-side and here (design §4). { href: "/insights", tKey: "nav.insights", @@ -183,6 +221,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/achievements", + sharedRecord: true, tKey: "nav.achievements", icon: Trophy, tourId: "nav-achievements", @@ -224,7 +263,15 @@ export const NAV_UTILITY_DESTINATIONS: ReadonlyArray = [ * tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two * surfaces share one definition of the utility links. */ -export function visibleUtilityDestinations(): NavUtilityDestination[] { +export function visibleUtilityDestinations( + opts: { sharedRecord?: boolean } = {}, +): NavUtilityDestination[] { + // v1.36.0 — the utility tail is account configuration by definition: + // Settings holds credentials, integrations, notification channels and grant + // management, and Notifications is the delegate's own device business. Every + // route behind both refuses under a switch; the tail simply stops being + // offered so nobody walks into the refusal. + if (opts.sharedRecord) return []; return [...NAV_UTILITY_DESTINATIONS]; } @@ -257,25 +304,53 @@ function isNavDestinationVisible( d: NavDestination, modules: ModuleVisibilityMap | undefined, mounted = true, + sharedRecord = false, ): boolean { + if (sharedRecord && d.sharedRecord !== true) return false; if (!d.requiresModule) return true; if (!mounted) return false; return modules?.[d.requiresModule] !== false; } +/** + * v1.36.0 — is this path part of what account sharing covers? + * + * Answers from the one destination list rather than from a second literal, so + * the mobile bar's fixed slots, the sidebar and the shell's deep-link guard + * cannot end up disagreeing about which surfaces a delegate is offered. + * + * Matches a destination and everything beneath it (`/measurements/123` rides + * `/measurements`), with the dashboard matching exactly — otherwise `"/"` + * would swallow every path in the app. A path no destination claims answers + * `false`: a surface nobody has classified is not one to open inside somebody + * else's record. + */ +export function isDestinationInSharedRecord(pathname: string): boolean { + return NAV_DESTINATIONS.some((d) => { + if (!d.sharedRecord) return false; + if (d.href === "/") return pathname === "/"; + return pathname === d.href || pathname.startsWith(`${d.href}/`); + }); +} + /** * The ordered destinations visible to this account — drops a module-gated * entry (mood, cycle, labs, coach, achievements …) when its module is * disabled in the account's resolved module map. Both bars start from this. * v1.18.0 — cycle is no longer a bespoke boolean: it is `requiresModule: * "cycle"` and reads the delegated `cycle` key from the same map. + * + * v1.36.0 — `sharedRecord` drops every entry that is not part of what sharing + * covers, so a delegate is not offered a door the server will shut. Paint + * only; see the `sharedRecord` field's docblock. */ export function visibleNavDestinations( modules: ModuleVisibilityMap | undefined, mounted = true, + sharedRecord = false, ): NavDestination[] { return NAV_DESTINATIONS.filter((d) => - isNavDestinationVisible(d, modules, mounted), + isNavDestinationVisible(d, modules, mounted, sharedRecord), ); } @@ -312,8 +387,14 @@ export function mobileMoreHubDestinations(opts: { modules: ModuleVisibilityMap | undefined; /** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */ mounted?: boolean; + /** v1.36.0 — acting on somebody else's record. Defaults to own. */ + sharedRecord?: boolean; }): MobileMoreHubEntry[] { - return visibleNavDestinations(opts.modules, opts.mounted ?? true) + return visibleNavDestinations( + opts.modules, + opts.mounted ?? true, + opts.sharedRecord ?? false, + ) .filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href)) .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); } diff --git a/src/components/layout/shared-record-banner.tsx b/src/components/layout/shared-record-banner.tsx new file mode 100644 index 000000000..ca31f12d1 --- /dev/null +++ b/src/components/layout/shared-record-banner.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Eye, Loader2, LogOut } from "lucide-react"; + +import { useAccountSwitch } from "@/hooks/use-account-switch"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; + +/** + * v1.36.0 — the strip that says whose record is open. + * + * This is the feature, not decoration around it. A caregiver who forgets they + * are switched will log their own blood pressure into somebody else's record, + * and there is no undo for a reading that was never theirs. So the banner + * states three things in one line — that this is not your record, whose it is, + * and that you can only read it — and carries the way out beside them. + * + * ## Why it looks like this + * + * Loud on purpose, within the token system. `bg-warning/15` + `border-warning/40` + * puts it in the one visual register the app reserves for "something about this + * screen is not normal", above the offline strip and the demo strip in the + * shell so it is the first line of chrome under any circumstance. Not + * `destructive` — nothing is broken and nothing is at risk of being lost; + * warning is the honest register for a context the user has to hold in mind. + * Not a tint on the page background either: a subtle wash is exactly what + * somebody stops seeing after the third switch, and the failure mode here is + * a person who has stopped noticing. + * + * The name is `text-foreground` and the qualifier `text-muted-foreground`, per + * the two-tier text rule — the person's name is content, the read-only note is + * meta. Mounted for every authenticated route inside `` (the + * `` pattern) so no page can forget it, and rendered from + * `accountAccess.active`, which the server resolves: the banner appears exactly + * when the server says the session is inside a record, never on a client guess. + * + * `role="status"` rather than `alert`: assistive tech announces the context + * once when it appears, without the interrupt an alert implies. `aria-live` is + * left to the role's implicit `polite`. + */ +export function SharedRecordBanner() { + const { user } = useAuth(); + const { t } = useTranslations(); + const switchAccount = useAccountSwitch(); + + const active = user?.accountAccess?.active ?? null; + if (!active) return null; + + const name = accountLabel(active); + + return ( +
+
+ ); +} diff --git a/src/components/layout/shared-record-grant-loss-bridge.tsx b/src/components/layout/shared-record-grant-loss-bridge.tsx new file mode 100644 index 000000000..f948dbf10 --- /dev/null +++ b/src/components/layout/shared-record-grant-loss-bridge.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; + +import { + subscribeToGrantLoss, + useLeaveSharedRecordOnGrantLoss, +} from "@/hooks/use-account-switch"; + +/** + * v1.36.0 — leave a record the server has stopped honouring. + * + * The switcher reset the contract asks for. A grant can end at any moment: the + * owner presses revoke, or an expiry passes. Revocation clears the delegate's + * acting sessions in the same transaction, so that path lands them back in + * their own account by itself. **Expiry does not** — nothing sweeps, the grant + * simply stops being live on the next request, and the session keeps its stamp. + * A browser in that state gets a 403 on every read while the banner still says + * it is inside somebody's record. Correct refusals under a screen that lies. + * + * So one subscriber watches the query cache for the stable + * `sharing.access.denied` code and, on the first one, clears the switch and + * reloads. Watching the cache rather than asking ~200 call sites to handle a + * code is the point: a per-call-site handler is a rule the next feature + * forgets, and forgetting it here means somebody is stuck. + * + * Fires once — `subscribeToGrantLoss` owns that, because `landOnRecord` never + * resolves (the document is being replaced) and a second trigger would queue a + * second reload behind a navigation already in flight. + */ +export function SharedRecordGrantLossBridge() { + const queryClient = useQueryClient(); + const leave = useLeaveSharedRecordOnGrantLoss(); + + useEffect( + () => subscribeToGrantLoss(queryClient, () => void leave()), + [queryClient, leave], + ); + + return null; +} diff --git a/src/components/layout/shared-record-unavailable.tsx b/src/components/layout/shared-record-unavailable.tsx new file mode 100644 index 000000000..aa9775faf --- /dev/null +++ b/src/components/layout/shared-record-unavailable.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Lock } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.36.0 — the page a delegate reached that sharing does not cover. + * + * The nav drops these destinations while a switch is on, so the way here is a + * bookmark, a browser back button, or a link somebody kept open. Without this + * panel the page would mount, fire its reads, collect a row of 403s, and paint + * a screen full of error cards that each say something went wrong — when + * nothing did. + * + * It says the honest thing instead: this surface is not part of what sharing + * covers, and here is the way back to your own record. Settings, credentials, + * integrations, notification channels and the AI surfaces are the account + * around a health record rather than the record itself; a delegate reads the + * record. + * + * Paint, not enforcement. Every route behind these pages refuses on its own, + * from a frozen allowlist this component cannot reach — if this file were + * deleted the delegate would see 403s, not data. + */ +export function SharedRecordUnavailable() { + const { t } = useTranslations(); + const switchAccount = useAccountSwitch(); + + return ( +
+ } + title={t("recordSharing.unavailable.title")} + description={t("recordSharing.unavailable.description")} + ctaSize="lg" + action={ + + } + /> +
+ ); +} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 0e6aa9cb9..bb86b5538 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -16,6 +16,7 @@ import { import Link from "next/link"; import { usePathname } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; +import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, @@ -58,12 +59,51 @@ function getInitials(name: string): string { .join(""); } +/** + * The sidebar footer's name-and-avatar block. + * + * A link into the account settings normally, and a plain block while this + * browser is acting on somebody else's record — the settings routes refuse + * under a switch, so the link would lead only to an explanation of why it did + * not work. It keeps naming the person at the keyboard either way, which is + * what a switched session needs it to do. + */ +function FooterIdentity({ + sharedRecord, + label, + children, +}: { + sharedRecord: boolean; + label: string; + children: React.ReactNode; +}) { + const className = + "flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors"; + if (sharedRecord) { + return
{children}
; + } + return ( + + {children} + + ); +} + function SidebarUserSection({ collapsed }: { collapsed: boolean }) { const { user } = useAuth(); const logout = useLogout(); const { theme, setTheme } = useTheme(); const { t } = useTranslations(); const avatarUrl = user?.avatarUrl ?? null; + // v1.36.0 — while this browser is acting on somebody else's record the + // account surfaces are unreachable, so the avatar stops being a door into + // them. It still names the person at the keyboard, which is exactly what a + // switched session needs it to do. + const sharedRecord = user?.accountAccess?.active != null; if (!user) return null; @@ -76,6 +116,23 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { ); + const identityBlock = ( + <> + + {avatarUrl && } + + {getInitials(user.username)} + + +
+

{user.username}

+ {user.email && ( +

{user.email}

+ )} +
+ + ); + const dropdownContent = ( - - - {avatarUrl && } - - {getInitials(user.username)} - - -
-

{user.username}

- {user.email && ( -

- {user.email} -

- )} -
- - + {sharedRecord ? ( +
{identityBlock}
+ ) : ( + + + {identityBlock} + + + )} - - - - {t("nav.notifications")} - - + {!sharedRecord && ( + + + + {t("nav.notifications")} + + + )} {/* The about section lives at the end of the settings shell nav; the avatar menu stays focused on account-level actions. */} @@ -146,6 +196,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { + {/* v1.36.0 — the records this account may open. Renders nothing when + nobody has shared one, so the menu is unchanged for every account + that does not use sharing. */} + logout.mutate()} @@ -188,10 +242,9 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { {/* R20 — the avatar + name in the expanded footer routes straight into the account settings; the kebab to the right still opens the rest of the user menu. */} - {avatarUrl && } @@ -207,7 +260,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {

)} - + visibleNavDestinations(user?.modules, mounted), - [user?.modules, mounted], + () => visibleNavDestinations(user?.modules, mounted, sharedRecord), + [user?.modules, mounted, sharedRecord], ); // Collapsed = the user's stored choice, or — with no stored choice — a // viewport default: tablet widths (md–lg, e.g. an iPad held upright) @@ -295,8 +352,10 @@ export function SidebarNav() { // role-gated, sidebar-only surface and is inserted separately below. const footerUtilityItems = useMemo( () => - visibleUtilityDestinations().filter((d) => d.href !== "/notifications"), - [], + visibleUtilityDestinations({ sharedRecord }).filter( + (d) => d.href !== "/notifications", + ), + [sharedRecord], ); function isUtilityActive(href: string) { diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index d03b572c9..2a64baa23 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -11,6 +11,7 @@ import { Shield, Sun, } from "lucide-react"; +import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; @@ -41,6 +42,8 @@ function getInitials(name: string): string { export function TopBar() { const { user, isLoading } = useAuth(); + // v1.36.0 — acting on somebody else's record; see the menu below. + const sharedRecord = user?.accountAccess?.active != null; const logout = useLogout(); const { theme, setTheme } = useTheme(); const { t } = useTranslations(); @@ -112,25 +115,34 @@ export function TopBar() { - - - - {t("nav.settings")} - - - - - - {t("nav.notifications")} - - - {isAdmin && ( - - - - {t("nav.admin")} - - + {/* v1.36.0 — the account utilities drop out while this browser is + acting on somebody else's record. Every route behind them + refuses under a switch, and a control that leads only to an + explanation of why it does not work is worse than no control. + The switcher below is how the person gets back. */} + {!sharedRecord && ( + <> + + + + {t("nav.settings")} + + + + + + {t("nav.notifications")} + + + {isAdmin && ( + + + + {t("nav.admin")} + + + )} + )} {/* v1.4.36 W4e — About moved into the Admin Console (`/admin/about`). The dropdown entry was redundant @@ -177,6 +189,11 @@ export function TopBar() {
+ {/* v1.36.0 — the same switcher slice the desktop sidebar menu + mounts. One component in both menus: a switcher a person can + reach on their laptop and not on their phone is a record they + can enter and cannot leave. */} + logout.mutate()} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index e137a2c9e..37dfa184d 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -21,6 +21,7 @@ import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; import { VersionPoller } from "@/components/version-poller"; import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; +import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications"; @@ -281,6 +282,11 @@ export function Providers({ + {/* v1.36.0 — a grant that ends while a browser is inside the record + leaves the session stamped and every read refused. One cache + subscriber notices the stable errorCode and puts the browser back + in its own account, rather than asking every call site to. */} + {children} diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 8970f8ae0..25f80a4c4 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -105,6 +105,10 @@ describe("SETTINGS_SECTION_SLUGS", () => { expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "security", + // v1.36.0 — account sharing: who may open this record, and whose this + // account may open. Beside `security`, and distinct from the `sharing` + // slug further down (the clinician share LINK). + "access", "modules", "integrations", "sources", diff --git a/src/components/settings/access-section.tsx b/src/components/settings/access-section.tsx new file mode 100644 index 000000000..0925b34bb --- /dev/null +++ b/src/components/settings/access-section.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { GrantInviteCard } from "@/components/settings/access/grant-invite-card"; +import { GrantsGivenCard } from "@/components/settings/access/grants-given-card"; +import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card"; +import { RecordActivityCard } from "@/components/settings/access/record-activity-card"; + +/** + * v1.36.0 — Settings → "Shared access". + * + * One section for one concept: who else can open this health record, and whose + * this account may open. Split across two pages it would be two half-answers — + * the settings IA rule this repository keeps rediscovering — so both directions + * and the activity that follows from them live in one column, in the order + * somebody reads them: + * + * 1. Invite — the act. + * 2. Who has access — the standing state, and the way to end it. + * 3. What I can open — the same, from the other side. + * 4. When it was opened — what actually happened. + * + * Distinct from Settings → "Health record" → sharing links, which mints a + * time-boxed read-only link to a REPORT for a clinician. This section is about + * another ACCOUNT on this instance being able to open the live record, which is + * a standing relationship rather than a document handed over once. + * + * Every card here reads and writes routes that refuse while the browser is + * acting on somebody else's record, and the section is unreachable in that + * state because the whole Settings entry drops out of the nav. Grant management + * is the one surface a delegate must have no reach into at all: a delegate who + * could invite, widen or transfer would be re-delegating access nobody granted + * them. + */ +export function AccessSection() { + return ( +
+ + + + +
+ ); +} diff --git a/src/components/settings/access/__tests__/access-panel.test.tsx b/src/components/settings/access/__tests__/access-panel.test.tsx new file mode 100644 index 000000000..2c82c6b6d --- /dev/null +++ b/src/components/settings/access/__tests__/access-panel.test.tsx @@ -0,0 +1,254 @@ +/** + * The shared-access panel: what each row says, and what the activity view + * admits about itself. + * + * SSR renders of the real cards with the reads stubbed at the hook boundary, + * so what is asserted is the paint. The mutations (revoke, renounce, accept) + * are proven where they can be: their invalidation contract in + * `src/lib/queries/__tests__/account-grants-invalidation.test.ts`, their + * confirmation gates in `src/__tests__/destructive-control-guard.test.ts`, and + * the end-to-end click in `e2e/account-sharing.spec.ts`. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { + GrantList, + GrantRow, + RecordActivity, +} from "@/lib/queries/use-account-grants"; + +const grantsRef: { value: GrantList } = { value: { given: [], received: [] } }; +const activityRef: { value: RecordActivity | undefined } = { value: undefined }; + +vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useAccountGrants: () => ({ + data: grantsRef.value, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useRecordActivity: () => ({ + data: activityRef.value, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useRevokeGrant: () => ({ mutate: vi.fn(), isPending: false }), + useRenounceGrant: () => ({ mutate: vi.fn(), isPending: false }), + useAcceptGrant: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { GrantsGivenCard } from "../grants-given-card"; +import { GrantsReceivedCard } from "../grants-received-card"; +import { RecordActivityCard } from "../record-activity-card"; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const BASE: GrantRow = { + id: "g1", + account: { id: "acct-1", username: "housemate", displayName: "Jo" }, + access: "READ", + state: "ACTIVE", + invitedAt: "2026-01-02T10:00:00.000Z", + acceptedAt: "2026-01-02T11:00:00.000Z", + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + revokedBy: null, +}; + +describe("who can open my record", () => { + it("says so honestly when nobody can", () => { + grantsRef.value = { given: [], received: [] }; + const html = render(); + expect(html).toContain("Nobody else can open your record"); + expect(html).not.toContain('data-slot="grants-given-list"'); + }); + + it("renders the state the server resolved, not one it worked out", () => { + // Every row carries a `state` the request resolver's own predicate + // produced. A panel that compared `expiresAt` to the clock would be a + // second opinion about somebody's access to a health record, and the two + // would disagree first in the dangerous direction. + grantsRef.value = { + given: [ + { ...BASE, id: "a", state: "ACTIVE" }, + { ...BASE, id: "p", state: "PENDING", acceptedAt: null }, + { + ...BASE, + id: "e", + state: "EXPIRED", + expiresAt: "2026-01-03T00:00:00.000Z", + }, + ], + received: [], + }; + const html = render(); + expect(html).toContain('data-grant-state="ACTIVE"'); + expect(html).toContain('data-grant-state="PENDING"'); + expect(html).toContain('data-grant-state="EXPIRED"'); + }); + + it("keeps an ended grant in the list, with who ended it", () => { + // The row IS the consent record. "Who had access, from when to when, and + // who ended it" is the question the panel exists to answer, and a list + // that dropped the ended rows could not answer it. + grantsRef.value = { + given: [ + { + ...BASE, + state: "REVOKED", + revokedAt: "2026-02-01T09:00:00.000Z", + revokedBy: "GRANTOR", + }, + ], + received: [], + }; + const html = render(); + expect(html).toContain('data-grant-state="REVOKED"'); + expect(html).toContain("You ended it"); + }); + + it("distinguishes a withdrawal from a handover", () => { + grantsRef.value = { + given: [ + { + ...BASE, + state: "REVOKED", + revokedAt: "2026-02-01T09:00:00.000Z", + revokedBy: "GRANTEE", + }, + ], + received: [], + }; + const html = render(); + // Two facts, one column. A trail that conflated them would misread an + // ordinary handover as a falling-out. + expect(html).toContain("Handed back"); + expect(html).not.toContain("You ended it"); + }); + + it("offers no way to end a grant that has already ended", () => { + grantsRef.value = { + given: [{ ...BASE, state: "REVOKED", revokedAt: "2026-02-01T09:00:00Z" }], + received: [], + }; + const ended = render(); + expect(ended).not.toContain('data-slot="grant-revoke"'); + + grantsRef.value = { given: [BASE], received: [] }; + const live = render(); + expect(live).toContain('data-slot="grant-revoke"'); + }); + + it("says a live grant has never been used rather than leaving a blank", () => { + grantsRef.value = { given: [{ ...BASE, lastUsedAt: null }], received: [] }; + const html = render(); + expect(html).toContain("never"); + }); +}); + +describe("records I can open", () => { + it("offers acceptance on an invitation and nothing else", () => { + grantsRef.value = { + given: [], + received: [{ ...BASE, state: "PENDING", acceptedAt: null }], + }; + const html = render(); + expect(html).toContain('data-slot="grant-accept"'); + // Nothing to open and nothing to hand back: an invitation confers nothing. + expect(html).not.toContain('data-slot="grant-open"'); + expect(html).not.toContain('data-slot="grant-renounce"'); + }); + + it("offers opening and handing back once it is active", () => { + grantsRef.value = { given: [], received: [BASE] }; + const html = render(); + expect(html).toContain('data-slot="grant-open"'); + expect(html).toContain('data-account-id="acct-1"'); + expect(html).toContain('data-slot="grant-renounce"'); + expect(html).not.toContain('data-slot="grant-accept"'); + }); +}); + +describe("when my record was opened", () => { + it("states its own window, from the number the server resolved", () => { + // A view that showed "the activity" without saying how far back it can + // see would imply a completeness it does not have: somebody checking last + // spring would read an empty list as "no" when the honest answer is + // "further back than this reaches". + activityRef.value = { entries: [], retentionDays: 90 }; + const html = render(); + expect(html).toContain('data-slot="record-activity-retention"'); + expect(html).toContain("last 90 days"); + }); + + it("prints the operator's window, never a compiled-in 365", () => { + activityRef.value = { entries: [], retentionDays: 30 }; + const html = render(); + expect(html).toContain("last 30 days"); + expect(html).not.toContain("365"); + }); + + it("says nothing about a window it has not been told", () => { + // A placeholder here would be exactly the invented value the sentence + // exists to avoid. + activityRef.value = undefined; + const html = render(); + expect(html).not.toContain('data-slot="record-activity-retention"'); + }); + + it("counts the openings rather than listing them one by one", () => { + activityRef.value = { + retentionDays: 365, + entries: [ + { + id: "a1", + actor: { id: "acct-1", username: "housemate", displayName: "Jo" }, + action: "sharing.record.accessed", + accesses: 40, + createdAt: "2026-02-03T08:00:00.000Z", + }, + ], + }; + const html = render(); + expect(html).toContain('data-slot="record-activity-row"'); + expect(html).toContain("Jo"); + expect(html).toContain("40"); + }); + + it("names a deleted account as one", () => { + // The actor column is deliberately not a foreign key, so the id outlives + // the person. Rendering nothing would read as though the owner had done + // it themselves. + activityRef.value = { + retentionDays: 365, + entries: [ + { + id: "a2", + actor: null, + action: "sharing.record.accessed", + accesses: 3, + createdAt: "2026-02-03T08:00:00.000Z", + }, + ], + }; + const html = render(); + expect(html).toContain("A deleted account"); + }); +}); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx new file mode 100644 index 000000000..1affa9a96 --- /dev/null +++ b/src/components/settings/access/grant-invite-card.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState } from "react"; +import { UserPlus } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { useInviteGrant } from "@/lib/queries/use-account-grants"; + +/** + * v1.36.0 — offering somebody read access to this record. + * + * One field, because there is one decision: who. The access level is not + * offered — v1 grants read and only read, and a control whose only setting is + * its default is a promise about a future release rather than a choice about + * this one. + * + * The invitation confers nothing until the other person accepts it. The card + * says so above the field rather than after the submit, because that is the + * part somebody needs to know BEFORE they type a name: nothing has been shared + * yet, and nothing will be until the other side agrees. + * + * Errors are named, not generic. "No account with that name" and "that person + * already has access" are different situations with different next steps, and + * the server publishes a stable `meta.errorCode` for each precisely so this + * card does not have to read prose to tell them apart. + */ +export function GrantInviteCard() { + const { t } = useTranslations(); + const invite = useInviteGrant(); + const [identifier, setIdentifier] = useState(""); + const [error, setError] = useState(null); + const [invited, setInvited] = useState(null); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + const value = identifier.trim(); + if (value.length === 0) return; + setError(null); + setInvited(null); + invite.mutate( + { identifier: value, expiresAt: null }, + { + onSuccess: (grant) => { + setIdentifier(""); + setInvited(grant.account.username); + }, + onError: (err) => setError(t(inviteErrorKey(err))), + }, + ); + }; + + return ( + + +
+
+ + setIdentifier(e.target.value)} + placeholder={t("recordSharing.invite.identifierPlaceholder")} + autoComplete="off" + maxLength={255} + /> +

+ {t("recordSharing.invite.identifierHint")} +

+
+ + {error && ( +

+ {error} +

+ )} + {invited && ( +

+ {t("recordSharing.invite.sent", { name: invited })} +

+ )} +
+
+ ); +} + +/** + * The message for a refused invitation. + * + * Branches on the stable `meta.errorCode` rather than the status alone: a 422 + * covers both a malformed identifier and an invitation somebody addressed to + * themselves, and telling those two apart is the difference between "fix the + * field" and "you already have your own record". + */ +function inviteErrorKey(err: unknown): string { + const code = + err instanceof ApiError && typeof err.meta?.errorCode === "string" + ? err.meta.errorCode + : null; + switch (code) { + case "sharing.invite.self": + return "recordSharing.invite.errorSelf"; + case "sharing.invite.duplicate": + return "recordSharing.invite.errorDuplicate"; + case "sharing.invite.invalid": + return "recordSharing.invite.errorInvalid"; + default: + break; + } + if (err instanceof ApiError && err.status === 404) { + return "recordSharing.invite.errorNoAccount"; + } + if (err instanceof ApiError && err.status === 429) { + return "recordSharing.invite.errorRateLimited"; + } + return "recordSharing.invite.errorFailed"; +} diff --git a/src/components/settings/access/grant-row.tsx b/src/components/settings/access/grant-row.tsx new file mode 100644 index 000000000..4825b6bd7 --- /dev/null +++ b/src/components/settings/access/grant-row.tsx @@ -0,0 +1,147 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; +import type { GrantRow, GrantState } from "@/lib/queries/use-account-grants"; + +/** + * v1.36.0 — one grant, from either side. + * + * The row's whole job is to say what a person can see and since when, without + * anybody having to read a timestamp and work out the answer. Two rules shape + * it: + * + * * **The state comes from the server.** `state` is resolved by the same + * function the request resolver's predicate is built on. The row renders + * it; it never compares `expiresAt` to the clock, because a panel that + * said ACTIVE about a grant the server had stopped honouring would be a + * second opinion about somebody's access to a health record. + * * **Ended rows stay.** A revoked grant is not gone from the list — the row + * IS the consent record, and "who had access, from when to when, and who + * ended it" is the question the panel exists to answer. It renders muted + * with its end named, rather than disappearing as though it never was. + * + * The person's name is `text-foreground` (it is the content); every date, + * level and last-used note is `text-muted-foreground` (they are meta). The + * status badge carries meaning rather than decoration, so it uses the semantic + * tokens: active is `success`, an unanswered invitation is `warning`, an ended + * one is neutral. + */ + +const STATE_VARIANT: Record< + GrantState, + "default" | "secondary" | "destructive" | "outline" +> = { + ACTIVE: "default", + PENDING: "secondary", + EXPIRED: "outline", + REVOKED: "outline", +}; + +export function GrantRowItem({ + grant, + /** What ended it reads differently depending on who is looking. */ + side, + actions, +}: { + grant: GrantRow; + side: "given" | "received"; + actions?: ReactNode; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const ended = grant.state === "REVOKED" || grant.state === "EXPIRED"; + + return ( +
  • +
    +
    + + {accountLabel(grant.account)} + + + {t(`recordSharing.state.${stateKey(grant.state)}`)} + +
    +

    + {grant.access === "WRITE" + ? t("recordSharing.row.canWrite") + : t("recordSharing.row.readOnly")} +

    +
    +
    +
    {t("recordSharing.row.invited")}
    +
    {fmt.dateTime(new Date(grant.invitedAt))}
    +
    + {grant.acceptedAt && ( +
    +
    {t("recordSharing.row.accepted")}
    +
    {fmt.dateTime(new Date(grant.acceptedAt))}
    +
    + )} + {grant.expiresAt && !ended && ( +
    +
    {t("recordSharing.row.expires")}
    +
    {fmt.dateTime(new Date(grant.expiresAt))}
    +
    + )} + {grant.state === "ACTIVE" && ( +
    +
    {t("recordSharing.row.lastUsed")}
    + {/* Absence reads as absence: a grant that has never been + exercised says so, rather than showing a blank. */} +
    + {grant.lastUsedAt + ? fmt.dateTime(new Date(grant.lastUsedAt)) + : t("recordSharing.row.neverUsed")} +
    +
    + )} + {grant.revokedAt && ( +
    +
    {endedByKey(grant.revokedBy, side, t)}
    +
    {fmt.dateTime(new Date(grant.revokedAt))}
    +
    + )} +
    +
    + {actions &&
    {actions}
    } +
  • + ); +} + +function stateKey(state: GrantState): string { + return state.toLowerCase(); +} + +/** + * "You withdrew it" versus "she handed it back" — the same column, read from + * two sides. `revokedBy` is what makes the distinction possible; conflating + * them would misreport an ordinary handover as a falling-out. + */ +function endedByKey( + revokedBy: GrantRow["revokedBy"], + side: "given" | "received", + t: (key: string) => string, +): string { + if (revokedBy === "GRANTOR") { + return side === "given" + ? t("recordSharing.row.endedByYou") + : t("recordSharing.row.endedByOwner"); + } + if (revokedBy === "GRANTEE") { + return side === "given" + ? t("recordSharing.row.handedBack") + : t("recordSharing.row.endedByYou"); + } + return t("recordSharing.row.ended"); +} diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx new file mode 100644 index 000000000..756447179 --- /dev/null +++ b/src/components/settings/access/grants-given-card.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Loader2, Users } from "lucide-react"; + +import { GrantRowItem } from "@/components/settings/access/grant-row"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ConfirmButton } from "@/components/ui/confirm-button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { useTranslations } from "@/lib/i18n/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; +import { + useAccountGrants, + useRevokeGrant, + type GrantRow, +} from "@/lib/queries/use-account-grants"; + +/** + * v1.36.0 — who can open this record. + * + * The compliance surface: at any moment the owner can see who has access, at + * what level, since when, and whether it is still being used. Ended grants stay + * in the list because the row is the consent record. + * + * Revoking asks once and then does it. No step-up, no typed confirmation — + * reducing access must never be harder than granting it was, and the one party + * here whose intent needs no verification is the person closing their own + * health record to somebody. The confirm dialog exists only because the click + * target sits in a list and a mis-tap should not end an access silently; it + * names the person and the act. + */ +export function GrantsGivenCard() { + const { t } = useTranslations(); + const { data, isLoading, isError, refetch } = useAccountGrants(); + const revoke = useRevokeGrant(); + + const given = data?.given ?? []; + + return ( + + +
    + {isLoading && ( + + )} + {isError && ( + void refetch()} + /> + )} + {!isLoading && !isError && given.length === 0 && ( + } + title={t("recordSharing.given.emptyTitle")} + description={t("recordSharing.given.emptyDescription")} + /> + )} + {given.length > 0 && ( +
      + {given.map((grant) => ( + + ))} +
    + )} +
    +
    + ); + + function renderRevoke(grant: GrantRow) { + // Only a live grant can be ended. An already-ended row keeps its history + // and loses its button, rather than offering an act that would 409. + if (grant.state !== "ACTIVE" && grant.state !== "PENDING") return null; + const name = accountLabel(grant.account); + return ( + revoke.mutate(grant.id)} + /> + ); + } +} diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx new file mode 100644 index 000000000..73ad296fe --- /dev/null +++ b/src/components/settings/access/grants-received-card.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { FolderOpen, Loader2 } from "lucide-react"; + +import { GrantRowItem } from "@/components/settings/access/grant-row"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; +import { useTranslations } from "@/lib/i18n/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; +import { + useAcceptGrant, + useAccountGrants, + useRenounceGrant, + type GrantRow, +} from "@/lib/queries/use-account-grants"; + +/** + * v1.36.0 — the records this account has been offered, and the ones it holds. + * + * The delegate's side. Three affordances, and the order they appear in is the + * order somebody meets them: + * + * * **Accept.** An invitation confers nothing until this button is pressed. + * That is the consent handshake, not a formality: being handed read access + * to another person's health record is not something to impose silently, + * and the acceptance is what the grant row records. + * * **Open.** Once accepted, the record can be opened from here as well as + * from the user menu — somebody who has just accepted is already looking + * at the row and should not have to go hunting for the switcher. + * * **Hand back.** The delegate's own way out, attributed as theirs so the + * owner's record can tell "I withdrew it" from "he gave it back". + */ +export function GrantsReceivedCard() { + const { t } = useTranslations(); + const { data, isLoading, isError, refetch } = useAccountGrants(); + const accept = useAcceptGrant(); + const renounce = useRenounceGrant(); + const switchAccount = useAccountSwitch(); + + const received = data?.received ?? []; + + return ( + + +
    + {isLoading && ( + + )} + {isError && ( + void refetch()} + /> + )} + {!isLoading && !isError && received.length === 0 && ( + } + title={t("recordSharing.received.emptyTitle")} + description={t("recordSharing.received.emptyDescription")} + /> + )} + {received.length > 0 && ( +
      + {received.map((grant) => ( + + ))} +
    + )} +
    +
    + ); + + function renderActions(grant: GrantRow) { + if (grant.state === "PENDING") { + return ( + + ); + } + if (grant.state !== "ACTIVE") return null; + const name = accountLabel(grant.account); + return ( +
    + + renounce.mutate(grant.id)} + /> +
    + ); + } +} diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx new file mode 100644 index 000000000..066ce1e30 --- /dev/null +++ b/src/components/settings/access/record-activity-card.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { History, Loader2 } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +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 { accountLabel } from "@/lib/sharing/account-access-view"; +import { + useRecordActivity, + type RecordActivityEntry, +} from "@/lib/queries/use-account-grants"; + +/** + * v1.36.0 — when this record was opened, and by whom. + * + * ## The card says how far back it can see + * + * Access rows are purged on a retention schedule (365 days by default, and the + * operator can shorten it). A view titled "activity" with no window stated + * would imply a completeness it does not have — somebody checking whether + * their record was opened last spring would read an empty list as "no" when + * the honest answer is "that is further back than this can see". So the bound + * is printed under the heading, and it is the number the SERVER resolved from + * the same setting the purge job reads, never a constant compiled in here. An + * instance running a 90-day window says 90. + * + * ## What a row is + * + * One row per delegate per day, with a count — not one row per request. A + * hundred identical lines saying "she read your record" is a way of answering + * nothing; "she was in your record on Tuesday, 40 times" is the answer somebody + * actually wants. Rows the owner performed themselves never appear here: this + * is the record of what SOMEBODY ELSE did, and the account's own security + * activity has its own surface. + */ +export function RecordActivityCard() { + const { t } = useTranslations(); + const { data, isLoading, isError, refetch } = useRecordActivity(); + + const entries = data?.entries ?? []; + + return ( + + +
    + {/* The window, stated. Rendered only once the server has said what it + is — a placeholder number here would be exactly the invented value + the sentence exists to avoid. */} + {data && ( +

    + {t("recordSharing.activity.retention", { + days: data.retentionDays, + })} +

    + )} + + {isLoading && ( + + )} + {isError && ( + void refetch()} + /> + )} + {!isLoading && !isError && entries.length === 0 && ( + } + title={t("recordSharing.activity.emptyTitle")} + description={t("recordSharing.activity.emptyDescription")} + /> + )} + {entries.length > 0 && ( +
      + {entries.map((entry) => ( + + ))} +
    + )} +
    +
    + ); +} + +function ActivityRow({ entry }: { entry: RecordActivityEntry }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + // A deleted account renders as one. The audit column is deliberately not a + // foreign key, so the id outlives the person; showing nothing would read as + // though the owner had done it themselves. + const who = entry.actor + ? accountLabel(entry.actor) + : t("recordSharing.activity.deletedAccount"); + + return ( +
  • + + {entry.accesses === null + ? t("recordSharing.activity.acted", { name: who }) + : t("recordSharing.activity.opened", { + name: who, + count: entry.accesses, + })} + + + {fmt.dateTime(new Date(entry.createdAt))} + +
  • + ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index ae891f2ec..eff2f4430 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -39,6 +39,7 @@ import { Thermometer, TrendingUp, User, + UsersRound, type LucideIcon, } from "lucide-react"; @@ -49,6 +50,7 @@ import type { SettingsSectionSlug } from "./section-slugs"; const SLUG_ICON: Record = { account: User, security: ShieldCheck, + access: UsersRound, modules: Blocks, integrations: Link2, sources: Layers, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 0f43f0b8a..d73f5eb53 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -53,6 +53,14 @@ export const SETTINGS_SECTION_SLUGS = [ // setup (TOTP + security keys), recovery codes, and passkey management. Sits // right after `account` so the two identity surfaces read as neighbours. "security", + // v1.36.0 — `access` ("Shared access"): which other accounts on this + // instance may open this health record, which records this one may open, and + // when they were opened. Sits beside `security` because it answers the same + // question those surfaces do — who can get at my account, and what have they + // been doing. Distinct from the `sharing` slug below, which is the clinician + // share LINK (a time-boxed read-only report handed to somebody outside the + // instance); this one is a standing relationship between two accounts on it. + "access", // v1.18.0 — `modules` ("Was du trackst") sits right after account: the // single front door for enabling/disabling secondary tracking domains. "modules", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 7bbe37097..e574259f5 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -38,6 +38,7 @@ import { SlidersHorizontal, Sparkles, User, + UsersRound, type LucideIcon, } from "lucide-react"; @@ -150,6 +151,16 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: ShieldCheck, group: "account", }, + // v1.36.0 — who else may open this record, and whose this account may open. + // In the Account group beside Security: both answer "who can get at my + // account and what have they done", and a person looking for one will look + // where the other is. + { + slug: "access", + titleKey: "settings.sections.access.title", + icon: UsersRound, + group: "account", + }, // v1.23 — "Data & Privacy" assembles the export / deletion / retention / // encryption / session / activity pieces into one coherent privacy pane. { diff --git a/src/hooks/__tests__/grant-loss.test.ts b/src/hooks/__tests__/grant-loss.test.ts new file mode 100644 index 000000000..c7a6740de --- /dev/null +++ b/src/hooks/__tests__/grant-loss.test.ts @@ -0,0 +1,124 @@ +/** + * Leaving a record the server has stopped honouring. + * + * Revocation clears the delegate's acting sessions in the same transaction, so + * that path puts the browser back by itself. Expiry does not — nothing sweeps, + * the grant simply stops being live on the next request, and the session keeps + * its stamp. From then on every read is a correct 403 under a banner that says + * the person is somewhere they are not. + * + * The subscriber is what closes that. It runs against a real `QueryClient` + * here, driven into the error state the way a failing read drives it, because + * "the effect calls subscribe" is a claim about source and "the callback fires + * when a read is refused" is the behaviour somebody depends on. + * + * Mutation check, run: widen the guard to fire on any error (drop the + * `isSharingAccessDenied` test) → "ignores an ordinary failure" goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +import { + isSharingAccessDenied, + isSharingNotPermitted, + subscribeToGrantLoss, +} from "@/hooks/use-account-switch"; +import { ApiError } from "@/lib/api/api-fetch"; +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options"; + +function client(): QueryClient { + return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }); +} + +/** Drive one query to the error state, the way a refused read does. */ +async function failWith(qc: QueryClient, error: unknown): Promise { + await qc + .fetchQuery({ + queryKey: ["probe", Math.random()], + queryFn: () => Promise.reject(error), + retry: false, + }) + .catch(() => {}); +} + +const DENIED = new ApiError("Account access denied", 403, { + errorCode: "sharing.access.denied", +}); +const NOT_PERMITTED = new ApiError("Not permitted", 403, { + errorCode: "sharing.not_permitted", +}); + +describe("the two sharing error codes", () => { + it("tells the two apart", () => { + // Different facts, different responses. One means the caller may no longer + // act as that account and must leave; the other means the surface is not + // part of what sharing covers and leaving would be the wrong answer. + expect(isSharingAccessDenied(DENIED)).toBe(true); + expect(isSharingNotPermitted(DENIED)).toBe(false); + expect(isSharingNotPermitted(NOT_PERMITTED)).toBe(true); + expect(isSharingAccessDenied(NOT_PERMITTED)).toBe(false); + }); + + it("reads the code, not the status or the prose", () => { + expect(isSharingAccessDenied(new ApiError("Forbidden", 403))).toBe(false); + expect(isSharingAccessDenied(new Error("Account access denied"))).toBe( + false, + ); + expect(isSharingAccessDenied(null)).toBe(false); + }); +}); + +describe("subscribeToGrantLoss", () => { + it("leaves when a read is refused for a grant that has ended", async () => { + const qc = client(); + const onLoss = vi.fn(); + const stop = subscribeToGrantLoss(qc, onLoss); + + await failWith(qc, DENIED); + stop(); + + expect(onLoss).toHaveBeenCalledTimes(1); + }); + + it("ignores an ordinary failure", async () => { + const qc = client(); + const onLoss = vi.fn(); + const stop = subscribeToGrantLoss(qc, onLoss); + + // A 500, a network blip and a not-permitted surface are all reasons to + // stay put. Reloading the app out of the record on any of them would turn + // a transient error into a lost context. + await failWith(qc, new ApiError("Server error", 500)); + await failWith(qc, NOT_PERMITTED); + await failWith(qc, new TypeError("fetch failed")); + stop(); + + expect(onLoss).not.toHaveBeenCalled(); + }); + + it("fires once, however many reads are refused", async () => { + // A switched page has several reads in flight and they all fail together. + // The callback replaces the document; a second one would queue a second + // navigation behind it. + const qc = client(); + const onLoss = vi.fn(); + const stop = subscribeToGrantLoss(qc, onLoss); + + await failWith(qc, DENIED); + await failWith(qc, DENIED); + await failWith(qc, DENIED); + stop(); + + expect(onLoss).toHaveBeenCalledTimes(1); + }); + + it("stops listening once unsubscribed", async () => { + const qc = client(); + const onLoss = vi.fn(); + subscribeToGrantLoss(qc, onLoss)(); + + await failWith(qc, DENIED); + + expect(onLoss).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts new file mode 100644 index 000000000..6d11b18dc --- /dev/null +++ b/src/hooks/use-account-switch.ts @@ -0,0 +1,196 @@ +"use client"; + +/** + * v1.36.0 — entering and leaving somebody else's record, from the browser. + * + * The switch is three acts in a fixed order, and the order is the whole + * safety argument: + * + * 1. **Ask the server.** `POST /api/account/switch` validates the grant and + * stamps the session. Nothing local changes until it answers, so a switch + * the server refuses leaves the browser exactly where it was. + * 2. **Empty every cache that survives a reload.** The IndexedDB query + * snapshot and the service worker's `healthlog-data-*` cache both answer + * reads from disk. Left in place, the first paint on the other side can + * be the PREVIOUS record's numbers under a banner naming the new one — + * and nothing in the page's own code would be wrong, because the answer + * would not have come from the page. Awaited, not fired: a wipe racing + * the navigation is a wipe that sometimes does not happen. + * 3. **Reload the document.** Not `router.refresh()`, not a query + * invalidation. A hard navigation is what discards the in-memory cache, + * every React state tree holding a rendered value, and every in-flight + * request that was issued against the old record. The reload IS the cache + * flush; the two lines above it are the parts a reload cannot reach. + * + * The hook returns from step 1 and never from step 3 — the page is gone. + * Callers therefore do not need a success state, only a pending one. + */ + +import { + useMutation, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; +import { useCallback } from "react"; +import { toast } from "sonner"; + +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister"; +import { queryKeys } from "@/lib/query-keys"; +import { setRecordScope } from "@/lib/query-keys/record-scope"; + +/** + * The two stable sharing error codes, as the envelope publishes them + * (`meta.errorCode`). Named here rather than typed inline so the reset + * below and the "not available while sharing" state cannot drift apart from + * what the server sends. + */ +export const SHARING_ACCESS_DENIED = "sharing.access.denied"; +export const SHARING_NOT_PERMITTED = "sharing.not_permitted"; + +/** The `meta.errorCode` on a failed request, when it carried one. */ +export function errorCodeOf(error: unknown): string | null { + if (!(error instanceof ApiError)) return null; + const code = error.meta?.errorCode; + return typeof code === "string" ? code : null; +} + +/** + * True when a request failed because the caller may no longer act as the + * account they are inside — the grant was revoked, lapsed, or never applied to + * this record. + * + * The correct response is not a retry and not an error card: it is to leave the + * record. A browser that stays inside it shows a banner claiming access it does + * not have, over a page that will 403 every read. + */ +export function isSharingAccessDenied(error: unknown): boolean { + return errorCodeOf(error) === SHARING_ACCESS_DENIED; +} + +/** + * True when the surface itself is not available while acting on another + * account — grant management, credentials, integrations, notification + * channels, anything that would let a delegate extend their own reach. + * + * A different fact from the one above and worth a different message: there is + * nothing wrong with the caller's access, the page simply is not part of what + * sharing covers. + */ +export function isSharingNotPermitted(error: unknown): boolean { + return errorCodeOf(error) === SHARING_NOT_PERMITTED; +} + +interface SwitchResponse { + actingAs: { + accountId: string; + username: string; + displayName: string | null; + access: "READ" | "WRITE"; + } | null; +} + +async function postSwitch(accountId: string | null): Promise { + return apiPost("/api/account/switch", { accountId }); +} + +/** + * Complete the switch: stamp the cache scope, empty the disk layers, reload. + * + * Extracted from the mutation so the grant-loss reset can reuse the exact same + * sequence. Two copies of "wipe, then reload" is one copy too many for a step + * whose order is the safety property. + */ +async function landOnRecord(accountId: string | null): Promise { + setRecordScope(accountId); + await clearOfflineCachesForRecordSwitch(); + window.location.assign("/"); + // The document is being replaced. Nothing after this runs, and the promise + // deliberately never settles so no caller paints a "done" state over a page + // that is on its way out. + return new Promise(() => {}); +} + +/** + * Switch into a record, or (with `null`) back into the caller's own. + * + * Errors surface as a toast and leave the browser in place — including the + * `sharing.access.denied` case, which means the grant ended between the + * switcher being painted and the click. The list the switcher renders comes + * from `/api/auth/me`, so the next boot will not offer it again. + */ +export function useAccountSwitch() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: queryKeys.accountSwitchMutation(), + mutationFn: async (accountId: string | null) => { + await postSwitch(accountId); + // The in-memory cache holds the record we are leaving. Clearing it here + // as well as reloading costs nothing and closes the window in which a + // failed navigation would leave the old entries reachable. + queryClient.clear(); + return landOnRecord(accountId); + }, + onError: (error: unknown) => { + toast.error( + isSharingAccessDenied(error) + ? t("recordSharing.switch.noLongerAvailable") + : t("recordSharing.switch.failed"), + ); + }, + }); +} + +/** + * Leave a record the server has stopped honouring. + * + * The switcher-reset the contract asks for: on `sharing.access.denied` the + * session is still stamped with an account it may no longer act on, and every + * delegable read from here on is a 403. Clearing the stamp puts the browser + * back in its own account rather than leaving somebody staring at a wall of + * refusals under a banner that says they are somewhere they are not. + * + * `POST /api/account/switch` with `null` is an actor surface and always + * succeeds — it is the way out, by design. If even that fails (offline, server + * down) the reload still happens: the stamp survives, but so does the honest + * refusal, and the alternative is a page that neither recovers nor says why. + */ +/** + * Watch the cache for the moment the server stops honouring the switch. + * + * A plain function over a QueryClient rather than an effect body, so a test + * can drive a real cache into the error state and check what happens — which + * is the only way to tell a subscriber that fires from one that was written + * and never wired. + * + * Fires `onLoss` at most once. The caller reloads the document, and a second + * trigger would queue a second navigation behind one already in flight. + */ +export function subscribeToGrantLoss( + queryClient: QueryClient, + onLoss: () => void, +): () => void { + let fired = false; + return queryClient.getQueryCache().subscribe((event) => { + if (fired) return; + if (event.type !== "updated") return; + if (event.query.state.status !== "error") return; + if (!isSharingAccessDenied(event.query.state.error)) return; + fired = true; + onLoss(); + }); +} + +export function useLeaveSharedRecordOnGrantLoss() { + return useCallback(async () => { + try { + await postSwitch(null); + } catch { + /* the reload below is the part that matters */ + } + await landOnRecord(null); + }, []); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index f0da3add3..b698f8033 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -22,6 +22,19 @@ import { storeTimezone } from "@/lib/timezone-mirror"; import type { ModuleKey } from "@/lib/modules/registry"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { setRecordScope } from "@/lib/query-keys/record-scope"; +import type { AccountAccess } from "@/lib/sharing/account-access-view"; + +/** + * v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back + * to when the payload carries no block, so every consumer reads a real object + * and none of them has to spell out what a missing block would mean. + */ +export const NO_ACCOUNT_ACCESS: AccountAccess = { + accounts: [], + active: null, + canSwitch: false, +}; /** * v1.18.6 — resume point for the module tour as the client sees it on @@ -162,6 +175,21 @@ export interface AuthUser { * payload so a missing map reads as all-available. */ moduleAvailability?: Partial>; + /** + * v1.36.0 — account sharing, resolved server-side. `accounts` is the + * switcher's menu, `active` is the record this browser is inside (null when + * it is in its own), `canSwitch` and per-entry `canWrite` are booleans to + * bind, never to compute. + * + * Nothing in the UI may derive a permission from this block: the server + * publishes what the caller may do and the client renders it, because two + * programs deciding one person's access is how they end up disagreeing. + * + * Optional so existing `AuthUser` test fixtures stay valid; `fetchMe` + * always populates it, coercing an absent block (an older server image) to + * the empty one — no accounts, no active record, no switcher. + */ + accountAccess?: AccountAccess; } /** @@ -264,6 +292,13 @@ async function fetchMe(): Promise { storeTimezone(typeof data.timezone === "string" ? data.timezone : ""); // A confirmed session: refresh the non-sensitive offline-relaunch marker. storeWasAuthenticated(); + // v1.36.0 — the record this browser is reading, straight from the server's + // resolved answer. This is the correcting write for the cache-scope mirror: + // whatever the switch flow stamped before its reload, the block is what the + // resolver will actually honour on the next request, so the mirror tracks it + // rather than the client's memory of what it asked for. + const accountAccess = coerceAccountAccess(data.accountAccess); + setRecordScope(accountAccess.active?.accountId ?? null); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, @@ -290,6 +325,28 @@ async function fetchMe(): Promise { data.moduleAvailability && typeof data.moduleAvailability === "object" ? data.moduleAvailability : {}, + accountAccess, + }; +} + +/** + * Read the sharing block off a payload that may not carry one. + * + * Fails CLOSED, unlike the module maps above: an older server image (or a + * malformed block) resolves to no accounts, no active record, and no switcher. + * The module gates fail open because a missing map must never blank the nav; + * this one fails closed because the affordance it drives asks the server to + * open somebody else's health record, and an affordance offered on a server + * that cannot honour it is worse than one that is missing. + */ +function coerceAccountAccess(value: unknown): AccountAccess { + if (value === null || typeof value !== "object") return NO_ACCOUNT_ACCESS; + const block = value as Partial; + if (!Array.isArray(block.accounts)) return NO_ACCOUNT_ACCESS; + return { + accounts: block.accounts, + active: block.active ?? null, + canSwitch: block.canSwitch === true, }; } @@ -358,6 +415,12 @@ export function useAuth() { */ export function clearCachesForSessionEnd(queryClient: QueryClient): void { queryClient.clear(); + // v1.36.0 — the cache-scope mirror is session state too. Left behind, the + // next account on this browser would boot with the previous session's record + // scope, which partitions its cache under a stranger's id until the first + // `/api/auth/me` corrects it. Harmless for correctness (the store it points + // at is wiped below) and free to get right. + setRecordScope(null); // The offline-relaunch marker is session state too: after a real session // end the next cold offline launch must land on the login gate, not the // cached shell. diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 61e705fde..d8224ce36 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -121,13 +121,188 @@ const switchResponse = z "The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login.", }); +const recordActivityEntry = z + .object({ + id: z.string(), + actor: grantParty + .nullable() + .describe( + "Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as though the owner had done it themselves.", + ), + action: z.string(), + accesses: z + .number() + .int() + .nullable() + .describe( + "How many times that delegate opened the record on that day, for a coalesced read row. Null for anything else — a single act has no count, and a 1 there would invite adding up numbers that mean different things.", + ), + createdAt: z.string(), + }) + .meta({ id: "RecordActivityEntry" }); + +const recordActivityResponse = z + .object({ + entries: z.array(recordActivityEntry), + retentionDays: z + .number() + .int() + .describe( + "How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it beside the rows.", + ), + }) + .meta({ + id: "RecordActivity", + description: + "The owner's record of what somebody else did in their record, bounded by the instance's audit retention.", + }); + const sharingRefusal = { description: "Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted nothing — the refusal is not an enumeration oracle.", content: { "application/json": { schema: errorEnvelope } }, }; +/** + * The per-request account selector, published once. + * + * No route entry references it yet, and that is the honest state of the + * contract: the routes that read a record other than the caller's own arrive + * with the delegable-surface migration, and each one will `$ref` this + * parameter as it lands. Publishing the selector now is what lets a native + * client be built against a tagged spec rather than against a description of + * one — the header is live in the resolver today, and a request that sends it + * to a route which has not declared the mode is refused rather than quietly + * served the caller's own data. + * + * Defined here rather than in the components block so the header's name stays + * in two files, which `src/__tests__/acting-account-boundary-guard.test.ts` + * holds it to: the resolver that reads it, and this one that publishes it. + */ +export const accountSelectorParameter: NonNullable< + NonNullable["parameters"] +>[string] = { + name: "X-HealthLog-Account", + in: "header", + required: false, + schema: { type: "string", maxLength: 64 }, + description: + "Act on another account's record for THIS request. Bearer transport only — the browser session carries its switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason.", +}; + +// ── What the client is told it may do ──────────────────────────────────────── + +const accountAccessEntry = z + .object({ + accountId: z + .string() + .describe( + "The account whose record it is — the value POST /api/account/switch takes, and the value the X-HealthLog-Account header carries.", + ), + username: z.string(), + displayName: z.string().nullable(), + access: z.enum(["read", "write"]), + canWrite: z + .boolean() + .describe( + "Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; never derive it.", + ), + }) + .meta({ + id: "AccountAccessEntry", + description: + "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL here would resolve to a refusal. Clients paint their initials fallback.", + }); + +export const accountAccessBlock = z + .object({ + accounts: z + .array(accountAccessEntry) + .describe("Every record this caller may open, newest grant first."), + active: accountAccessEntry + .nullable() + .describe( + "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a banner can name the person without joining two fields. Null when the caller is in their own record, and ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses one.", + ), + canSwitch: z + .boolean() + .describe("Whether there is anywhere to switch to. Bind it directly."), + }) + .meta({ + id: "AccountAccess", + description: + "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field.", + }); + +/** + * The account payload, documented for the one field this release adds. + * + * Loose on purpose. `GET /api/auth/me` has never been in this spec, and its + * remaining fields are the account's own preference envelope — thirty-odd keys + * that no contract has ever enumerated. Specifying `accountAccess` and + * declaring the rest open is honest; enumerating a subset and closing the + * object would publish a contract the server does not keep. + */ +const accountPayload = z + .looseObject({ + id: z.string(), + username: z.string(), + accountAccess: accountAccessBlock, + }) + .meta({ + id: "AccountPayload", + description: + "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this payload still describes the CALLER — their preferences, their modules, their identity — because display preferences belong to the person at the keyboard rather than to the record they are reading.", + }); + export const accountSharingPaths: NonNullable = { + "/api/auth/me": { + get: { + tags: ["Auth"], + summary: "The signed-in account, and what sharing lets it do", + description: + "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call.", + responses: { + ...stdResponses, + "200": { + description: "The account payload.", + content: { + "application/json": { + schema: dataEnvelope(accountPayload, "AccountPayloadEnvelope"), + }, + }, + }, + "403": { + description: + "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/account/activity": { + get: { + tags: ["Account sharing"], + summary: "Who opened this record, and when", + description: + "Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Not delegable: a grant is not an introduction to the other people in the household.", + responses: { + ...stdResponses, + "200": { + description: "The activity, and how far back it can see.", + content: { + "application/json": { + schema: dataEnvelope( + recordActivityResponse, + "RecordActivityEnvelope", + ), + }, + }, + }, + "403": sharingRefusal, + }, + }, + }, "/api/account/grants": { get: { tags: ["Account sharing"], diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 6e09c83bf..c64c16678 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -21,7 +21,10 @@ */ import type { ZodOpenApiObject } from "zod-openapi"; -import { accountSharingPaths } from "./account-sharing"; +import { + accountSelectorParameter, + accountSharingPaths, +} from "./account-sharing"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; import { allergyPaths } from "./allergies"; import { authPaths } from "./auth"; @@ -149,6 +152,11 @@ export const openApiComponents: NonNullable = { medicationDoseHistoryImportFatalReasonEnum, CoachPrefs: coachPrefsSchema, }, + // v1.36.0 — the per-request account selector, defined in the sharing route + // module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps + // holding the header's name to two files: the resolver that reads it, and + // the one place that publishes it. + parameters: { AccountSelector: accountSelectorParameter }, securitySchemes: { bearerAuth: { type: "http", diff --git a/src/lib/pwa/__tests__/query-persister-shared-record.test.ts b/src/lib/pwa/__tests__/query-persister-shared-record.test.ts new file mode 100644 index 000000000..9b7626450 --- /dev/null +++ b/src/lib/pwa/__tests__/query-persister-shared-record.test.ts @@ -0,0 +1,213 @@ +/** + * Nothing of somebody else's record reaches this device's disk, and nothing on + * it is painted while somebody else's record is open. + * + * The full page reload a switch performs clears the in-memory cache. These two + * layers are the ones it cannot reach: the IndexedDB snapshot, and (through + * the same wipe helper) the service worker's read-data cache. Between them + * they are the reason a switched browser can paint the previous record's + * numbers with nothing in the page's own code being wrong — the answer would + * not have come from the page. + * + * The IndexedDB here is a small fake rather than a mock of the persister's own + * calls. A test that asserted "idbSet was not called" would prove the code + * took a branch; this one proves the store is empty, which is the fact + * somebody's privacy depends on. + * + * Mutation checks, both run: + * - delete the `isReadingSharedRecord()` guard in `flush` → "writes nothing + * to disk while inside another record" goes red, with the snapshot found + * in the store. + * - delete the guard in `restorePersistedQueryCache` → "restores nothing + * while inside another record" goes red on the cache-size line, with one + * entry hydrated. The line above it stays GREEN under that mutation, which + * is the point of having both: the record-scoped hash already makes the + * restored entry unreadable, so an assertion about readability alone would + * have reported success about a control that was no longer there. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options"; +import { + restorePersistedQueryCache, + startPersistingQueryCache, +} from "@/lib/pwa/query-persister"; +import { queryKeys } from "@/lib/query-keys"; +import { + __resetRecordScopeForTests, + setRecordScope, +} from "@/lib/query-keys/record-scope"; + +const BUILD = "test-build"; + +// ── A minimal in-memory IndexedDB ──────────────────────────────────────────── +// +// Only the three calls the persister makes: `open`, `get`, `put`/`delete` +// inside a transaction. Small enough to read in one sitting, real enough that +// the assertions below are about stored bytes rather than about call counts. + +const store = new Map(); + +function fakeRequest(result: T) { + const req: { + result: T; + onsuccess: (() => void) | null; + onerror: (() => void) | null; + onupgradeneeded: (() => void) | null; + } = { result, onsuccess: null, onerror: null, onupgradeneeded: null }; + queueMicrotask(() => req.onsuccess?.()); + return req; +} + +function fakeObjectStore() { + return { + get: (key: string) => fakeRequest(store.get(key)), + put: (value: unknown, key: string) => { + store.set(key, value); + return fakeRequest(undefined); + }, + delete: (key: string) => { + store.delete(key); + return fakeRequest(undefined); + }, + }; +} + +function installFakeIndexedDb() { + (globalThis as { indexedDB?: unknown }).indexedDB = { + open: () => { + const req: { + result: unknown; + onsuccess: (() => void) | null; + onerror: (() => void) | null; + onupgradeneeded: (() => void) | null; + } = { + result: { + createObjectStore: () => {}, + close: () => {}, + transaction: () => { + const tx: { + objectStore: () => ReturnType; + oncomplete: (() => void) | null; + onerror: (() => void) | null; + } = { + objectStore: fakeObjectStore, + oncomplete: null, + onerror: null, + }; + queueMicrotask(() => tx.oncomplete?.()); + return tx; + }, + }, + onsuccess: null, + onerror: null, + onupgradeneeded: null, + }; + queueMicrotask(() => req.onsuccess?.()); + return req; + }, + }; +} + +/** Let the persister's microtask chains and its 1 s debounce run. */ +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(1500); +} + +const originalIdb = (globalThis as { indexedDB?: unknown }).indexedDB; + +beforeEach(() => { + vi.useFakeTimers(); + store.clear(); + __resetRecordScopeForTests(); + installFakeIndexedDb(); +}); + +afterEach(() => { + vi.useRealTimers(); + if (originalIdb === undefined) { + delete (globalThis as { indexedDB?: unknown }).indexedDB; + } else { + (globalThis as { indexedDB?: unknown }).indexedDB = originalIdb; + } +}); + +function appClient(): QueryClient { + return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }); +} + +describe("persisting while inside another record", () => { + it("writes the dashboard to disk in the caller's own record", async () => { + // The positive control, and the reason the refusal below means anything: + // without it, "the store is empty" and "the persister does nothing at all + // in this test harness" are the same sentence. + const client = appClient(); + const stop = startPersistingQueryCache(client, BUILD); + client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 }); + await settle(); + stop(); + + expect(store.size).toBeGreaterThan(0); + }); + + it("writes nothing to disk while inside another record", async () => { + setRecordScope("acct-owner"); + const client = appClient(); + const stop = startPersistingQueryCache(client, BUILD); + client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 92 }); + await settle(); + stop(); + + // Not "a smaller payload" and not "the wrong key" — nothing. The + // allowlist decides which families are low-sensitivity enough to keep on + // this device, and that judgement was made about the account's own data. + expect(store.size).toBe(0); + }); +}); + +describe("restoring while inside another record", () => { + /** Seed the store the way a session in its own record would have left it. */ + async function seedOwnSnapshot(): Promise { + const writer = appClient(); + const stop = startPersistingQueryCache(writer, BUILD); + writer.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 }); + await settle(); + stop(); + expect(store.size).toBeGreaterThan(0); + } + + it("hydrates the persisted dashboard in the caller's own record", async () => { + await seedOwnSnapshot(); + + const reader = appClient(); + await restorePersistedQueryCache(reader, BUILD); + await vi.advanceTimersByTimeAsync(0); + + expect(reader.getQueryData(queryKeys.dashboardSnapshot())).toEqual({ + weightKg: 71, + }); + }); + + it("restores nothing while inside another record", async () => { + await seedOwnSnapshot(); + setRecordScope("acct-owner"); + + const reader = appClient(); + await restorePersistedQueryCache(reader, BUILD); + await vi.advanceTimersByTimeAsync(0); + + // The disk holds the DELEGATE's own dashboard. Painting it under a banner + // saying "you are in her record" is the mis-context failure this feature + // exists to prevent, in the one place a full reload cannot clear. + expect(reader.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined(); + + // And the entry is not merely unreachable — it was never hydrated. The + // line above passes with the guard deleted, because the record-scoped + // hash already makes a snapshot written in one record unreadable in + // another; asserting only that would be a check that cannot fail for the + // control it names. This one counts what landed in the cache, so removing + // the guard leaves exactly one entry here and turns it red. + expect(reader.getQueryCache().getAll()).toHaveLength(0); + }); +}); diff --git a/src/lib/pwa/query-client-options.ts b/src/lib/pwa/query-client-options.ts index 4f36b77dd..1d944428f 100644 --- a/src/lib/pwa/query-client-options.ts +++ b/src/lib/pwa/query-client-options.ts @@ -1,5 +1,7 @@ import type { DefaultOptions, QueryClient } from "@tanstack/react-query"; +import { recordScopedQueryKeyHashFn } from "@/lib/query-keys/record-scope"; + export const MEANINGFUL_HIDDEN_INTERVAL_MS = 60_000; interface VisibilityTarget { @@ -57,6 +59,13 @@ export function subscribeToMeaningfulVisibilityRefresh( * regardless, the offline `fetch` rejects immediately, and `onError` fires — an * honest failure the form surfaces (and the global `OfflineMutationToaster` * backstops), instead of a spinner-of-death plus silent write loss. + * + * v1.36.0 — `queryKeyHashFn` folds the record being read into the cache + * identity of every entry in the app. Without it `["dashboard","snapshot"]` + * names two different people's data and the cache cannot tell them apart. See + * `src/lib/query-keys/record-scope.ts` for why this sits on the hash rather + * than on the key arrays (the arrays feed prefix invalidation and the + * import-time dependent-key bundles, both of which would break). */ export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = { queries: { @@ -64,6 +73,7 @@ export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = { gcTime: 10 * 60 * 1000, refetchOnWindowFocus: false, retry: 1, + queryKeyHashFn: recordScopedQueryKeyHashFn, }, mutations: { networkMode: "always", diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index 805b44f04..7fbd5cdd3 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -33,6 +33,7 @@ import { } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; +import { isReadingSharedRecord } from "@/lib/query-keys/record-scope"; const DB_NAME = "healthlog-query-cache"; const STORE = "kv"; @@ -173,6 +174,12 @@ export async function restorePersistedQueryCache( buildVersion: string, ): Promise { if (typeof indexedDB === "undefined") return; + // v1.36.0 — a browser inside somebody else's record restores nothing. The + // store holds the caller's OWN dashboard (it is the only thing that was ever + // written to it, see the flush below), and painting that under a banner + // saying "viewing her record" is the mis-context failure this feature is + // built to prevent, in the one place a full reload cannot clear. + if (isReadingSharedRecord()) return; try { const payload = await idbGet(); if (!payload) return; @@ -245,6 +252,13 @@ export function startPersistingQueryCache( let timer: ReturnType | undefined; const flush = () => { + // v1.36.0 — never write another person's record to this device. The + // allowlist below decides WHICH families are low-sensitivity enough to + // persist; that judgement was made about the account's own data and does + // not carry over to a record they were merely given to read. Refusing at + // the flush (rather than filtering keys) also means the last good snapshot + // of the caller's own dashboard survives a switch untouched. + if (isReadingSharedRecord()) return; const state = dehydrate(queryClient, { shouldDehydrateQuery: (query) => query.state.status === "success" && isPersistableKey(query.queryKey), @@ -338,3 +352,26 @@ export async function clearOfflineCachesForSessionEnd(): Promise { } } } + +/** + * v1.36.0 — the same wipe, before a record switch in either direction. + * + * Deliberately the same bytes as the session-end wipe rather than a narrower + * one, and deliberately named for the act that calls it. + * + * The full page reload the switch performs clears the in-memory cache; these + * two layers are the ones that survive it. The IndexedDB snapshot holds the + * dashboard the browser was last looking at, and the service worker's + * `healthlog-data-*` cache answers `/api/…` reads from disk on a + * stale-while-revalidate branch — so without this call, the first paint after + * switching into a record can be the previous record's numbers, served by the + * worker, under a banner naming the new one. Nothing about the page's own code + * would be wrong; the answer would just come from somewhere the page does not + * control. + * + * Run it BEFORE the reload and await it. A wipe racing the navigation is a + * wipe that sometimes does not happen, and "sometimes" is the whole failure. + */ +export async function clearOfflineCachesForRecordSwitch(): Promise { + await clearOfflineCachesForSessionEnd(); +} diff --git a/src/lib/queries/__tests__/account-grants-invalidation.test.ts b/src/lib/queries/__tests__/account-grants-invalidation.test.ts new file mode 100644 index 000000000..df165898f --- /dev/null +++ b/src/lib/queries/__tests__/account-grants-invalidation.test.ts @@ -0,0 +1,81 @@ +/** + * A grant transition refreshes everything it can have changed. + * + * The standing rule this checks is not "the mutation calls invalidate" — that + * is a claim about the source. It is "the three reads a grant transition makes + * stale come back invalidated", and the only thing that can answer it is a + * real `QueryClient` with those three reads in it. + * + * The third one is the interesting one. The grant list and the activity feed + * are the surface a person is looking at; `["auth","me"]` is the account + * payload, which carries `accountAccess` — the switcher's menu and the banner. + * Forget it and a revoked record keeps its entry in the menu: the affordance + * still on screen, the click already refused by the server. + * + * Mutation check, run: drop `queryKeys.authMe()` from `grantDependentKeys` + * and "refreshes the account payload" goes red. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options"; +import { invalidateGrantReads } from "@/lib/queries/use-account-grants"; +import { queryKeys } from "@/lib/query-keys"; +import { __resetRecordScopeForTests } from "@/lib/query-keys/record-scope"; + +beforeEach(() => { + __resetRecordScopeForTests(); +}); + +function seeded(): QueryClient { + const client = new QueryClient({ + defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS, + }); + client.setQueryData(queryKeys.accountGrants(), { given: [], received: [] }); + client.setQueryData(queryKeys.accountActivity(), { + entries: [], + retentionDays: 365, + }); + client.setQueryData(queryKeys.authMe(), { id: "u1" }); + return client; +} + +describe("invalidateGrantReads", () => { + it("refreshes the grant list", () => { + const client = seeded(); + invalidateGrantReads(client); + expect( + client.getQueryCache().find({ queryKey: queryKeys.accountGrants() }) + ?.state.isInvalidated, + ).toBe(true); + }); + + it("refreshes the owner's record-activity feed", () => { + const client = seeded(); + invalidateGrantReads(client); + expect( + client.getQueryCache().find({ queryKey: queryKeys.accountActivity() }) + ?.state.isInvalidated, + ).toBe(true); + }); + + it("refreshes the account payload, which carries the switcher", () => { + const client = seeded(); + invalidateGrantReads(client); + expect( + client.getQueryCache().find({ queryKey: queryKeys.authMe() })?.state + .isInvalidated, + ).toBe(true); + }); + + it("starts from three live, valid entries", () => { + // Non-zero proof. Without this, a client that failed to seed and a client + // whose entries were all invalidated look identical from the assertions + // above — every `find()` would return undefined and `?.state` would make + // each one a quiet `undefined !== true`. + const client = seeded(); + const all = client.getQueryCache().getAll(); + expect(all).toHaveLength(3); + expect(all.every((q) => q.state.isInvalidated)).toBe(false); + }); +}); diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts new file mode 100644 index 000000000..8ea22df03 --- /dev/null +++ b/src/lib/queries/use-account-grants.ts @@ -0,0 +1,167 @@ +"use client"; + +/** + * v1.36.0 — the reads and writes behind the shared-access panel. + * + * One module for the whole surface, because every mutation here invalidates + * the same three things and the standing rule about cache invalidation is + * easiest to keep when there is one place that can break it. `grantDependentKeys` + * (`@/lib/query-keys`) is that place: the grant list, the owner's activity + * feed, and the account payload — the last because `accountAccess` is what + * paints the switcher and the banner, and a revoke that refreshed the panel but + * not the payload leaves a switcher entry pointing at a record the server has + * just closed. + * + * Nothing in here decides anything. `state` arrives resolved from the same + * function the request resolver's own predicate is built on, so the panel + * cannot render "active" on a row the server has stopped honouring. + */ + +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; + +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { grantDependentKeys, queryKeys } from "@/lib/query-keys"; + +/** The other party to a grant, as far as anyone is told about them. */ +export interface GrantParty { + id: string; + username: string; + displayName: string | null; +} + +export type GrantState = "PENDING" | "ACTIVE" | "EXPIRED" | "REVOKED"; + +export interface GrantRow { + id: string; + account: GrantParty; + access: "READ" | "WRITE"; + state: GrantState; + invitedAt: string; + acceptedAt: string | null; + expiresAt: string | null; + lastUsedAt: string | null; + revokedAt: string | null; + revokedBy: "GRANTOR" | "GRANTEE" | null; +} + +export interface GrantList { + /** Grants this account has offered on its own record. */ + given: GrantRow[]; + /** Grants offered to this account on somebody else's. */ + received: GrantRow[]; +} + +export interface RecordActivityEntry { + id: string; + actor: GrantParty | null; + action: string; + accesses: number | null; + createdAt: string; +} + +export interface RecordActivity { + entries: RecordActivityEntry[]; + /** + * How far back the feed can see, in days — resolved by the server from the + * same setting the purge job reads. The view states it rather than implying + * the list is everything that ever happened. + */ + retentionDays: number; +} + +export function useAccountGrants(enabled = true) { + return useQuery({ + queryKey: queryKeys.accountGrants(), + queryFn: () => apiGet("/api/account/grants"), + enabled, + }); +} + +export function useRecordActivity(enabled = true) { + return useQuery({ + queryKey: queryKeys.accountActivity(), + queryFn: () => apiGet("/api/account/activity"), + enabled, + }); +} + +/** + * Refresh everything a grant transition can have changed. + * + * A plain function over a QueryClient rather than a hook body, so a test can + * hand it a real client, seed the three reads and check they came back + * invalidated — which is the only way to tell an invalidation that runs from + * one that was written and never wired. + */ +export function invalidateGrantReads(queryClient: QueryClient): void { + for (const key of grantDependentKeys) { + void queryClient.invalidateQueries({ queryKey: key }); + } +} + +/** + * The same refresh, bound to the mounted client. + * + * Shared by all four mutations rather than written out per hook: the day one + * of them forgets the account payload is the day a revoked record keeps its + * switcher entry. + */ +function useInvalidateGrants() { + const queryClient = useQueryClient(); + return () => invalidateGrantReads(queryClient); +} + +export interface InviteInput { + identifier: string; + /** ISO-8601 with offset, or null for a grant that runs until somebody ends it. */ + expiresAt: string | null; +} + +export function useInviteGrant() { + const invalidate = useInvalidateGrants(); + return useMutation({ + mutationKey: queryKeys.accountGrantInvite(), + mutationFn: (input: InviteInput) => + apiPost("/api/account/grants", input), + onSuccess: invalidate, + }); +} + +export function useAcceptGrant() { + const invalidate = useInvalidateGrants(); + return useMutation({ + mutationKey: queryKeys.accountGrantAccept(), + mutationFn: (grantId: string) => + apiPost(`/api/account/grants/${grantId}/accept`), + onSuccess: invalidate, + }); +} + +export function useRevokeGrant() { + const invalidate = useInvalidateGrants(); + return useMutation({ + mutationKey: queryKeys.accountGrantRevoke(), + mutationFn: (grantId: string) => + apiDelete( + `/api/account/grants/${grantId}`, + ), + onSuccess: invalidate, + }); +} + +export function useRenounceGrant() { + const invalidate = useInvalidateGrants(); + return useMutation({ + mutationKey: queryKeys.accountGrantRenounce(), + mutationFn: (grantId: string) => + apiPost( + `/api/account/grants/${grantId}/renounce`, + ), + onSuccess: invalidate, + }); +} diff --git a/src/lib/query-keys/__tests__/record-scope.test.ts b/src/lib/query-keys/__tests__/record-scope.test.ts new file mode 100644 index 000000000..befe0bc56 --- /dev/null +++ b/src/lib/query-keys/__tests__/record-scope.test.ts @@ -0,0 +1,154 @@ +/** + * The account dimension of the query cache. + * + * These run against a REAL `QueryClient` configured with the app's own default + * options, not against the hash function in isolation. The question worth + * answering is not "does the string change" — it is "can a value written while + * reading one record be read back while reading another", and only the cache + * itself can answer that. + * + * Mutation check, verified: delete `queryKeyHashFn` from + * `QUERY_CLIENT_DEFAULT_OPTIONS` and the two cross-record leaks below go red + * with the previous record's data returned verbatim. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options"; +import { queryKeys } from "@/lib/query-keys"; +import { + __resetRecordScopeForTests, + getRecordScope, + isReadingSharedRecord, + recordScopedQueryKeyHashFn, + setRecordScope, +} from "@/lib/query-keys/record-scope"; + +function appClient(): QueryClient { + return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }); +} + +beforeEach(() => { + __resetRecordScopeForTests(); +}); + +describe("the record scope", () => { + it("starts in the caller's own record", () => { + expect(getRecordScope()).toBeNull(); + expect(isReadingSharedRecord()).toBe(false); + }); + + it("moves, and comes back", () => { + setRecordScope("acct-owner"); + expect(getRecordScope()).toBe("acct-owner"); + expect(isReadingSharedRecord()).toBe(true); + + setRecordScope(null); + expect(getRecordScope()).toBeNull(); + expect(isReadingSharedRecord()).toBe(false); + }); +}); + +describe("the hash function", () => { + it("is TanStack's own hash while reading the caller's own record", () => { + // The overwhelming majority of sessions never switch. Nothing about their + // cache identity may change, or every persisted entry in the field is + // orphaned by this release. + expect(recordScopedQueryKeyHashFn(["dashboard", "snapshot"])).toBe( + JSON.stringify(["dashboard", "snapshot"]), + ); + }); + + it("sorts object keys, like the hash it replaces", () => { + // TanStack's `hashKey` is stable across key order inside an object so two + // spellings of one filter share a slot. Losing that would fragment the + // cache in a way no test of ours would otherwise notice. + const a = recordScopedQueryKeyHashFn(["k", { b: 2, a: 1 }]); + const b = recordScopedQueryKeyHashFn(["k", { a: 1, b: 2 }]); + expect(a).toBe(b); + }); + + it("gives one key two identities in two records", () => { + const own = recordScopedQueryKeyHashFn(queryKeys.dashboardSnapshot()); + setRecordScope("acct-owner"); + const shared = recordScopedQueryKeyHashFn(queryKeys.dashboardSnapshot()); + expect(shared).not.toBe(own); + }); +}); + +describe("the cache cannot serve one record's data while reading another", () => { + it("hides an entry written in the caller's own record", () => { + const client = appClient(); + client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 }); + expect(client.getQueryData(queryKeys.dashboardSnapshot())).toEqual({ + weightKg: 71, + }); + + setRecordScope("acct-owner"); + + // The delegate's own numbers must be unreachable from inside somebody + // else's record. This is the assertion the whole hash exists for. + expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined(); + }); + + it("hides an entry written in a shared record once the browser leaves", () => { + const client = appClient(); + setRecordScope("acct-owner"); + client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 92 }); + + setRecordScope(null); + + expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined(); + }); + + it("keeps two records' entries apart at the same time", () => { + const client = appClient(); + setRecordScope("acct-a"); + client.setQueryData(queryKeys.dashboardSnapshot(), { record: "a" }); + setRecordScope("acct-b"); + client.setQueryData(queryKeys.dashboardSnapshot(), { record: "b" }); + + expect(client.getQueryData(queryKeys.dashboardSnapshot())).toEqual({ + record: "b", + }); + setRecordScope("acct-a"); + expect(client.getQueryData(queryKeys.dashboardSnapshot())).toEqual({ + record: "a", + }); + }); +}); + +describe("prefix invalidation still reaches every slot", () => { + it("invalidates by key ARRAY, which the hash never touched", () => { + // The reason the dimension lives on the hash and not on the key arrays. + // `measurementDependentKeys` and every other bundle in the factory were + // built at import time, before any record was known; had the arrays grown + // a scope element, none of them would match anything ever again — and + // nothing would have failed, the caches would simply have stopped + // refreshing after a write. + const client = appClient(); + setRecordScope("acct-owner"); + client.setQueryData(queryKeys.measurementsList(LIST_PARAMS), [1, 2, 3]); + + client.invalidateQueries({ queryKey: queryKeys.measurements() }); + + const entry = client + .getQueryCache() + .find({ queryKey: queryKeys.measurementsList(LIST_PARAMS) }); + expect(entry).toBeDefined(); + expect(entry?.state.isInvalidated).toBe(true); + }); +}); + +const LIST_PARAMS = { + type: undefined, + sourceEq: undefined, + from: undefined, + to: undefined, + valueMin: undefined, + valueMax: undefined, + page: 1, + sortBy: "measuredAt", + sortDir: "desc", + mode: "raw", +} as const; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 572f3fee6..0085b1415 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -36,6 +36,7 @@ import { mentalHealthKeys } from "./mental-health"; import { moodKeys } from "./mood"; import { nutrientKeys } from "./nutrients"; import { settingsKeys } from "./settings"; +import { sharingKeys } from "./sharing"; import { workoutKeys } from "./workouts"; export const queryKeys = { @@ -62,8 +63,25 @@ export const queryKeys = { ...documentKeys, ...customMetrics, ...nutrientKeys, + ...sharingKeys, }; +/** + * v1.36.0 — every read a grant transition can make stale. + * + * Inviting, accepting, revoking and renouncing all change the same three + * answers: the grant list, the owner's record-activity feed, and + * `accountAccess` on the account payload — which is what drives the switcher + * and the banner. A revoke that refreshed the panel but not the payload would + * leave a switcher entry pointing at a record the server had just closed: the + * affordance still on screen, the click already refused. + */ +export const grantDependentKeys = [ + queryKeys.accountGrants(), + queryKeys.accountActivity(), + queryKeys.authMe(), +]; + /** * Keys that should be invalidated when a measurement is created, updated or * deleted. Kept here so dashboards, insights, and targets always stay in sync. diff --git a/src/lib/query-keys/record-scope.ts b/src/lib/query-keys/record-scope.ts new file mode 100644 index 000000000..93468e317 --- /dev/null +++ b/src/lib/query-keys/record-scope.ts @@ -0,0 +1,156 @@ +/** + * v1.36.0 — the account dimension of the query cache. + * + * Every cached read in this app is about one health record. Nothing in the key + * factory says which one, because until now there was only ever one: the + * signed-in account's. Once a browser can act on somebody else's record, a key + * like `["dashboard","snapshot"]` names two different answers, and a cache that + * cannot tell them apart will hand the second reader the first one's data. + * That is this repository's known cache-poison class, on health data, across + * two people. + * + * The fix is one line at the root rather than a dimension threaded through + * twenty-three key modules: a `queryKeyHashFn` on the QueryClient. The hash is + * what the cache stores entries under and what `getQueryData` looks them up by, + * so folding the record id into it partitions every entry in the app at once. + * Key ARRAYS are untouched, which matters more than it looks: prefix + * invalidation (`invalidateQueries({ queryKey: ["measurements"] })`) and the + * module-scope dependent-key bundles in `./index.ts` both match on the array, + * were computed at import time before any record was known, and keep working + * unchanged. Prefixing the arrays instead would have silently broken every one + * of those bundles. + * + * ## Why this is defence in depth and not the primary control + * + * Switching records is a full page reload (design §2), so the in-memory cache + * is empty on the other side of it and this hash can only matter if that ever + * stops being true. The primary control for the layers that DO survive a + * reload — the IndexedDB snapshot and the service worker's data cache — is the + * wipe the switch performs before reloading, in `switchRecord()`. Both are + * cheap; only one of them is the reason the feature is safe. + * + * ## Why the mirror is in localStorage + * + * The scope has to be known synchronously at boot, before `/api/auth/me` + * answers, because the persister decides whether to restore from disk in the + * first effect after mount. A switch is a property of the SESSION — it is + * carried on the session row, not per tab — so every tab of the browser is + * switched at once, which is what localStorage models and sessionStorage does + * not. + * + * A mirror can go stale, and the honest question is what a stale one costs. + * Nothing, because the disk layers are wiped on every switch in both + * directions: a mirror claiming a record the session has left points the + * persister at a store that was emptied when it left, and a mirror claiming + * "own" while the session is inside a record points it at a store that was + * emptied on the way in and never written to since (the persister refuses to + * write while switched — see `isPersistableKey`'s caller). Either way the + * worst case is an empty restore and a skeleton, never another person's data. + * `fetchMe` re-stamps the mirror from the server's resolved answer on every + * boot, so the staleness window is one round trip. + */ +import type { QueryKey } from "@tanstack/react-query"; + +const STORAGE_KEY = "healthlog-record-scope"; + +/** + * The in-process copy. Seeded from storage on first read so the hash function + * — which runs on every single query — never touches localStorage. + */ +let scope: string | null = null; +let seeded = false; + +function readMirror(): string | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw === null || raw.length === 0 ? null : raw; + } catch { + // Private mode, disabled storage, or SSR. "Own record" is the safe + // reading: it is what every browser reported before this feature existed. + return null; + } +} + +/** + * The record this browser is currently reading, or null when it is reading its + * own. + * + * Server-side this stays null for the life of the process: the mirror is only + * ever seeded from browser storage, and `setRecordScope` is called from client + * code alone. That matters more than it looks — the value is a module-level + * variable, so a server that could write it would be sharing one scope across + * every request it serves. + */ +export function getRecordScope(): string | null { + if (!seeded) { + scope = typeof window === "undefined" ? null : readMirror(); + seeded = true; + } + return scope; +} + +/** + * Point the cache at a record, or (with `null`) back at the caller's own. + * + * Called from exactly two places: the switch flow, before it reloads the page, + * and `fetchMe`, which corrects the mirror from the server's resolved + * `accountAccess.active` on every account read. The second is what keeps a + * mirror written by a switch that the server has since ended from surviving + * past one round trip. + */ +export function setRecordScope(accountId: string | null): void { + scope = accountId; + seeded = true; + if (typeof window === "undefined") return; + try { + if (accountId === null) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, accountId); + } + } catch { + /* storage unavailable — the in-process copy still partitions this tab */ + } +} + +/** True while this browser is reading somebody else's record. */ +export function isReadingSharedRecord(): boolean { + return getRecordScope() !== null; +} + +/** + * The QueryClient's key hash function. + * + * Identical to TanStack's own `hashKey` when the browser is in its own record + * — a `JSON.stringify` with sorted object keys — so nothing about the app's + * cache changes for the overwhelming majority of sessions. Inside a shared + * record every entry lands under a distinct hash, which is what makes an + * inherited entry unreachable rather than merely stale. + */ +export function recordScopedQueryKeyHashFn(queryKey: QueryKey): string { + const key = JSON.stringify(queryKey, (_, val) => + isPlainObject(val) + ? Object.keys(val) + .sort() + .reduce>((result, k) => { + result[k] = (val as Record)[k]; + return result; + }, {}) + : val, + ); + const record = getRecordScope(); + return record === null ? key : `record:${record}|${key}`; +} + +/** TanStack's own predicate, copied rather than imported — it is not exported. */ +function isPlainObject(value: unknown): value is Record { + if (Object.prototype.toString.call(value) !== "[object Object]") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; +} + +/** @internal — lets a test start from a known state. */ +export const __resetRecordScopeForTests = (): void => { + scope = null; + seeded = false; +}; diff --git a/src/lib/query-keys/sharing.ts b/src/lib/query-keys/sharing.ts new file mode 100644 index 000000000..7673b585d --- /dev/null +++ b/src/lib/query-keys/sharing.ts @@ -0,0 +1,37 @@ +/** + * Query keys — account sharing (v1.36.0). + * Part of the centralized factory; aggregated in `./index.ts`. + * + * Note what these keys deliberately do NOT carry: the acting account. That + * dimension lives on the QueryClient's `queryKeyHashFn` + * (`./record-scope.ts`), which folds it into every entry in the app at once + * — including these. Adding it here as well would partition the same cache + * twice under two rules, which is the two-deciders shape in miniature. + * + * The reads below are all non-delegable by construction: `/api/account/grants` + * and the record-activity feed both resolve through a bare `requireAuth()` and + * refuse while a switch is on. They are still record-scoped like everything + * else, so a session that switches out and back never reads a grant list + * cached under the wrong scope. + */ +export const sharingKeys = { + /** + * Grants in both directions (`GET /api/account/grants`). One key for one + * endpoint: the route answers "who can see my record, and whose can I see" + * in a single payload precisely so no client renders half of it. + */ + accountGrants: () => ["account", "grants"] as const, + + /** + * The owner's record-activity feed (`GET /api/account/activity`) — who + * opened their record, and when. + */ + accountActivity: () => ["account", "activity"] as const, + + /** Mutation keys — kept in the factory so no bare array reaches a call site. */ + accountGrantInvite: () => ["account", "grants", "invite"] as const, + accountGrantAccept: () => ["account", "grants", "accept"] as const, + accountGrantRevoke: () => ["account", "grants", "revoke"] as const, + accountGrantRenounce: () => ["account", "grants", "renounce"] as const, + accountSwitchMutation: () => ["account", "switch"] as const, +}; diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts new file mode 100644 index 000000000..264e3693c --- /dev/null +++ b/src/lib/sharing/account-access-view.ts @@ -0,0 +1,58 @@ +/** + * v1.36.0 — the shape of the sharing block, and nothing else. + * + * Types only, with no imports at all, because both ends read them: the route + * that builds the block (`account-access.ts`, which talks to Postgres) and the + * client that renders it. Declaring them beside the server code would put a + * module that imports Prisma on the client's import graph — erased at compile + * today, one careless value export away from being bundled tomorrow. The + * boundary is cheaper to keep than to rediscover. + * + * What these types are FOR is stated once here and holds everywhere they are + * used: they carry resolved answers. `canWrite` and `canSwitch` are decisions + * the server has already made; a client that recomputed either from the rest + * of the payload would be the second program deciding one person's access. + */ + +/** One account this caller may act on. */ +export interface AccountAccessEntry { + /** The account whose record it is — the value `POST /api/account/switch` takes. */ + accountId: string; + username: string; + displayName: string | null; + /** The grant's level. `"read"` throughout v1. */ + access: "read" | "write"; + /** May this caller change that record. Resolved server-side; `false` in v1. */ + canWrite: boolean; +} + +/** The `accountAccess` block on `GET /api/auth/me`. */ +export interface AccountAccess { + /** Every account this caller may open, newest grant first. */ + accounts: AccountAccessEntry[]; + /** + * The record this browser is inside right now, resolved to a full entry — + * not an id the client has to look up. Null when it is in its own record, + * and always null on the Bearer transport. + */ + active: AccountAccessEntry | null; + /** Is there anywhere to switch to. */ + canSwitch: boolean; +} + +/** + * The name to put in front of a person, given what the payload published. + * + * `displayName` when the account set one, the username otherwise. This is a + * presentation fallback and not a derivation of anything the server decided — + * both values are published, and which of the two reads better is the client's + * business. Kept here so the banner, the switcher and the sharing panel cannot + * disagree about what to call somebody. + */ +export function accountLabel(entry: { + displayName: string | null; + username: string; +}): string { + const named = entry.displayName?.trim(); + return named && named.length > 0 ? named : entry.username; +} diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts new file mode 100644 index 000000000..1a0fe3f54 --- /dev/null +++ b/src/lib/sharing/account-access.ts @@ -0,0 +1,97 @@ +/** + * v1.36.0 — what a caller may do with account sharing, resolved server-side. + * + * This module answers three questions for one caller, once per `/api/auth/me`: + * which records may I open, which one am I inside right now, and what may I do + * in it. Every answer is a VALUE, not an input to a client-side calculation. + * + * That is the whole design of this file and it is worth stating plainly, + * because the tempting alternative is one line shorter: publish the grant rows + * and let the client work out what they mean. Then two programs decide one + * person's access — the resolver in `src/lib/api-handler.ts`, and whichever + * client is being read today — and they answer differently the first time an + * expiry, a revocation or a second access level appears. The client renders + * `canSwitch` and `canWrite`; it never computes them, and there is nothing in + * the payload it could compute them from. + * + * `canWrite` is `false` for every entry in v1, because `inviteGrant` writes + * READ and only READ. It is resolved here through {@link grantAllows} against + * the real row rather than hardcoded, so the day a WRITE grant exists the + * answer changes without this file being edited — and, more importantly, so + * that nobody has to remember that it should. + * + * What is deliberately NOT published: the other account's avatar. The avatar + * bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the + * caller's own), so a URL here would resolve to a 403 and paint a broken + * image. Opening that route to a delegate is a classification decision about + * a route, not a decision the account payload gets to make on the side. + * Clients paint the initials fallback they already have. + */ +import { prisma } from "@/lib/db"; +import type { + AccountAccess, + AccountAccessEntry, +} from "@/lib/sharing/account-access-view"; +import { grantAllows, isGrantActive } from "@/lib/sharing/grants"; + +/** + * Resolve the sharing block for one caller. + * + * The block is always present, even for an account with no grants in either + * direction: an empty `accounts` array with `canSwitch: false` says "nobody has + * shared anything with you", which is a different statement from a missing + * field (which says "this server does not know about sharing"). Absence reads + * as absence. + * + * Takes the whole auth context rather than a pair of ids, so the carrier column + * is named here and in no route file — the boundary guard in + * `src/__tests__/acting-account-boundary-guard.test.ts` keeps that list short + * on purpose, and a field plucked out at every call site is how such a list + * grows one entry per surface. + * + * The session's stamp is deliberately NOT trusted to mean access: an entry + * appears in `active` only if the same pass that + * built `accounts` admitted it. So a stamp left behind by a grant that lapsed + * between two requests reads as "not switched" here, which is exactly what the + * request resolver will independently decide about the next delegated read. + * The client's job on seeing that mismatch is to clear the stamp, not to + * reason about it. + */ +export async function resolveAccountAccess(auth: { + user: { id: string }; + session: { actingAsUserId?: string | null }; +}): Promise { + const actorId = auth.user.id; + const stamped = auth.session.actingAsUserId ?? null; + const now = new Date(); + + // Narrow in SQL on the one condition the partial unique index makes cheap, + // and let the state machine decide the rest — the same split + // `findActiveGrant` uses, and for the same reason: a SQL predicate spelling + // out "accepted and not expired" beside a TypeScript one is two places + // deciding one question. + const rows = await prisma.accountGrant.findMany({ + where: { granteeId: actorId, revokedAt: null }, + orderBy: { createdAt: "desc" }, + include: { + grantor: { select: { id: true, username: true, displayName: true } }, + }, + }); + + const accounts: AccountAccessEntry[] = rows + .filter((grant) => isGrantActive(grant, now)) + .map((grant) => ({ + accountId: grant.grantor.id, + username: grant.grantor.username, + displayName: grant.grantor.displayName, + access: grant.access === "WRITE" ? "write" : "read", + canWrite: grantAllows(grant, "write", now), + })); + + const active = + stamped === null + ? null + : (accounts.find((a) => a.accountId === stamped) ?? null); + + return { accounts, active, canSwitch: accounts.length > 0 }; +} diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts new file mode 100644 index 000000000..ca6b4b39a --- /dev/null +++ b/tests/integration/sharing-account-access.test.ts @@ -0,0 +1,333 @@ +/** + * The `accountAccess` block on `GET /api/auth/me`, over the real route, + * against real Postgres. + * + * The block is the only thing either client is allowed to know about its own + * sharing permissions, so what is tested here is not "the field is present" + * but "the field says the same thing the request resolver would". Every case + * below is a state the grant table can actually be in — pending, active, + * expired, revoked, self — and the question each time is whether the payload + * would let a client paint a switcher entry the server would then refuse. + * + * Nothing is mocked below the route. A unit test with a stubbed Prisma could + * prove the route calls the resolver; it could not prove that a grant which + * lapsed an hour ago drops out of the list, which is the property the whole + * block exists to carry. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: () => {}, + delete: () => {}, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { ACCOUNT_SELECTOR_HEADER } from "@/lib/api-handler"; +import { hashToken } from "@/lib/auth/hmac"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `access-${suffix}`, + email: `access-${suffix}@example.test`, + displayName: `Access ${suffix}`, + role: "USER", + onboardingCompletedAt: new Date(), + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +interface AccountAccessEntry { + accountId: string; + username: string; + displayName: string | null; + access: "read" | "write"; + canWrite: boolean; +} + +interface AccountAccessBlock { + accounts: AccountAccessEntry[]; + active: AccountAccessEntry | null; + canSwitch: boolean; +} + +async function readMe(): Promise<{ + status: number; + accountAccess: AccountAccessBlock; + id: string; +}> { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + const body = (await res.json()) as { + data: { id: string; accountAccess: AccountAccessBlock }; + }; + return { + status: res.status, + accountAccess: body.data?.accountAccess, + id: body.data?.id, + }; +} + +/** An accepted, never-ending grant from `grantorId` to `granteeId`. */ +async function grantAccess( + grantorId: string, + granteeId: string, + extra: { expiresAt?: Date | null; acceptedAt?: Date | null } = {}, +) { + return getPrismaClient().accountGrant.create({ + data: { + grantorId, + granteeId, + access: "READ", + invitedAt: new Date(), + acceptedAt: + extra.acceptedAt === undefined ? new Date() : extra.acceptedAt, + expiresAt: extra.expiresAt ?? null, + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("accountAccess — what the payload publishes", () => { + it("is present and empty for an account nobody has shared with", async () => { + const alone = await makeUser("alone"); + await signIn(alone.id); + + const { status, accountAccess } = await readMe(); + + expect(status).toBe(200); + // Present, not missing. A client that got no field could not tell "this + // server does not do sharing" from "nobody shared anything with me", and + // would have to guess which. + expect(accountAccess).toEqual({ + accounts: [], + active: null, + canSwitch: false, + }); + }); + + it("lists an accepted grant with the level resolved, and canWrite false", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([ + { + accountId: owner.id, + username: owner.username, + displayName: owner.displayName, + access: "read", + canWrite: false, + }, + ]); + expect(accountAccess.canSwitch).toBe(true); + expect(accountAccess.active).toBeNull(); + }); + + it("omits a pending invitation — being offered access is not having it", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id, { acceptedAt: null }); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + expect(accountAccess.canSwitch).toBe(false); + }); + + it("omits a grant that has lapsed, against the request clock", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id, { + expiresAt: new Date(Date.now() - 60_000), + }); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + }); + + it("omits a revoked grant", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const grant = await grantAccess(owner.id, delegate.id); + await getPrismaClient().accountGrant.update({ + where: { id: grant.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + }); + + it("never lists a grant this account GAVE — the block is about what it may open", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + // The owner signs in. They gave the access; they did not receive any. + await signIn(owner.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + expect(accountAccess.canSwitch).toBe(false); + }); +}); + +describe("accountAccess — the active record", () => { + it("resolves the stamped account to a full entry, not just an id", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + const { accountAccess, id } = await readMe(); + + // Still the delegate's own payload — the switch changes which record the + // delegable routes read, never whose preferences /me returns. + expect(id).toBe(delegate.id); + expect(accountAccess.active).toEqual({ + accountId: owner.id, + username: owner.username, + displayName: owner.displayName, + access: "read", + canWrite: false, + }); + // The active entry is one of the listed ones, by construction. A banner + // that had to join the two could render unnamed when they disagreed. + expect(accountAccess.accounts).toContainEqual(accountAccess.active); + }); + + it("reads as not-switched when the stamp outlives the grant", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const grant = await grantAccess(owner.id, delegate.id); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + // The grant lapses while the browser sits inside the record. Expiry has + // no session cleanup — only revocation does — so the stamp survives it. + await getPrismaClient().accountGrant.update({ + where: { id: grant.id }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const { accountAccess } = await readMe(); + + // The payload agrees with what the resolver will decide about the next + // delegated read: there is no live grant, so there is no active record. + // The client's job is to clear the stamp, which is what the + // `sharing.access.denied` reset does. + expect(accountAccess.active).toBeNull(); + expect(accountAccess.accounts).toEqual([]); + }); +}); + +describe("accountAccess — the actor surface refuses a selector header", () => { + it("refuses a Bearer caller that attaches an account selector", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + + const raw = `hlk_${delegate.id}-token`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId: delegate.id, + name: "account-access-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${raw}`); + headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id); + + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + + // The account payload is DEFINED as the caller's own. A client that + // attached a selector to it believes it is asking about the owner and is + // not; answering correctly to the wrong question is how a support ticket + // about "data that looks wrong" starts. + expect(res.status).toBe(403); + expect(body.meta?.errorCode).toBe("sharing.not_permitted"); + }); + + it("serves a Bearer caller with no selector, and reports no active record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + + const raw = `hlk_${delegate.id}-plain`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId: delegate.id, + name: "account-access-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${raw}`); + + const { status, accountAccess } = await readMe(); + + expect(status).toBe(200); + // The token transport carries its selector per request, so this payload + // is never inside a record. `accounts` still answers "which records may + // I open", which is what the native switcher renders. + expect(accountAccess.active).toBeNull(); + expect(accountAccess.accounts).toHaveLength(1); + expect(accountAccess.canSwitch).toBe(true); + }); +});