diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 86c2b2da4..0f13b06e8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12743,6 +12743,43 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/managed-profiles/{id}/guardians: + get: + tags: + - Account sharing + summary: Who looks after a managed profile + description: "The live Guardians of one managed profile — every unexpired, unrevoked MANAGE grant on it, PENDING and + ACTIVE alike. Cookie transport only, by the same structural argument as the rest of this family: it resolves + through the session and never falls through to the Bearer branch. A fresh second factor is NOT required, unlike + every act on the same profile: this is a read, and it discloses nothing about a party the caller cannot already + read on their own sharing panel. An ACTOR surface — the profile is named in the path and the caller acts as + themselves, so it answers from the Guardian's own account rather than only while switched into the record. + Anyone who is not an active Guardian of THIS profile gets the same 404 as an unknown id, including a READ, WRITE + or MANAGE delegate of an ordinary record: the refusal is not an enumeration oracle. Count the ACTIVE rows to + know whether removing a Guardian is possible — a PENDING invitation does not satisfy the last-Guardian floor." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The profile's live Guardians, oldest invitation first. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileGuardianListEnvelope" + "401": *a1 + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`). The two are byte-identical." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/account/switch: post: tags: @@ -37258,6 +37295,57 @@ components: - data - error additionalProperties: false + ManagedProfileGuardianListEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ManagedProfileGuardian" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfileGuardian: + type: object + properties: + grantId: + type: string + description: The grant row. This is the value `DELETE /api/managed-profiles/{id}/guardians/{grantId}` takes. + account: + $ref: "#/components/schemas/AccountGrantParty" + state: + type: string + enum: + - PENDING + - ACTIVE + description: "PENDING means the invitation has been sent and not yet accepted. A PENDING Guardian does NOT count toward + the last-Guardian floor: the server counts accepted, unexpired MANAGE grants, so inviting somebody and then + trying to leave is still refused until they accept. Say so before the act rather than letting it arrive as a + 409." + invitedAt: + type: string + acceptedAt: + anyOf: + - type: string + - type: "null" + required: + - grantId + - account + - state + - invitedAt + - acceptedAt + additionalProperties: false + description: "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than + listed with a state: the question is who looks after this record now." AccountSwitchEnvelope: type: object properties: diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index b44c2ea4e..c0b7fce2e 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -65,6 +65,28 @@ export const E2E_OWNER = { role: "USER", } as const; +/** + * v1.37.0 — the account that creates and administers managed profiles. + * + * Its own account, and not one of the others, for one reason: every route in + * the managed-profile family resolves `requireFreshMfa`, which refuses an + * account with NO second factor enrolled (`auth.stepup.mfa_not_enrolled`). So + * this account carries a confirmed TOTP secret, and an account with a confirmed + * secret cannot be logged in by the password-only capture below. The setup + * therefore logs it in FIRST and stamps the factor afterwards — see the + * enrolment step at the end of `globalSetup`. + * + * Enrolling the factor on a shared account would have changed behaviour for + * every spec that touches a step-up-gated surface, starting with the MANAGE + * invitation on the sharing panel. + */ +export const E2E_GUARDIAN = { + email: "e2e-guardian@healthlog.test", + username: "e2e-guardian", + password: "Rt5!Nm8xQ3wZ6bJp", + role: "USER", +} as const; + /** A separate delegate session for the eight scoped-record browser journeys. */ export const E2E_SCOPE_DELEGATE = { email: "e2e-scope-delegate@healthlog.test", @@ -196,6 +218,16 @@ export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateFenceOffline.json", ); +/** + * The managed-profile journey's own jar. Its own session row, like every other + * switching spec, so a run at CI's worker count cannot have two specs moving + * the same session's record selector. + */ +export const GUARDIAN_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateGuardian.json", +); + export const CROSS_TAB_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateCrossTab.json", @@ -297,6 +329,45 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // v1.37.0 — the managed-profile guardian. Seeded exactly like the other + // fixture accounts; its second factor is stamped AFTER the login capture, + // because the password-only capture cannot complete a TOTP login. + 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, + totp_confirmed_at = NULL`, + [ + cuid(), + E2E_GUARDIAN.username, + E2E_GUARDIAN.email, + await hashPassword(E2E_GUARDIAN.password), + now, + ], + ); + + // Whatever a previous run created. A managed profile is a real account row, + // so a suite that left one behind would find the guardian looking after two + // records on the next run and the roster assertions would read the wrong + // one. Deleting the profile takes its grants with it. + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [E2E_GUARDIAN.username], + ); + // The scoped-record journey uses a dedicated delegate so its preseeded // grants cannot affect the invitation lifecycle exercised by the sharing // journey above. Its own module preferences are explicitly off: a target @@ -645,6 +716,21 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH); + + // v1.37.0 — the guardian's jar, and only then its second factor. + // + // Order is the whole of it. `requireFreshMfa` refuses an account with no + // factor enrolled, so the managed-profile routes are unreachable without + // this; and `captureAuthState` posts a password to `/api/auth/login`, which + // for a TOTP account answers with a challenge rather than a session. Log in + // first, enrol second. The freshness stamp on the session is refreshed by + // the spec immediately before it acts — the window is five minutes and a + // full suite run is longer than that. + await capture(E2E_GUARDIAN, GUARDIAN_STORAGE_STATE_PATH); + await pool.query( + `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, + [E2E_GUARDIAN.username, now], + ); } finally { await pool.end(); } diff --git a/e2e/setup/managed-profile-fixture.ts b/e2e/setup/managed-profile-fixture.ts new file mode 100644 index 000000000..f010017db --- /dev/null +++ b/e2e/setup/managed-profile-fixture.ts @@ -0,0 +1,111 @@ +/** + * v1.37.0 — the step-up the managed-profile journey needs, and nothing else. + * + * ## Why a fixture exists at all + * + * Every route in the managed-profile family resolves + * `requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)`. That gate has two halves: + * the account must have a second factor enrolled, and the SESSION must carry a + * `mfaVerifiedAt` newer than five minutes. `global-setup.ts` supplies the first + * half once; the second cannot be supplied once, because a full suite run takes + * longer than the window and the journey would be gated on how long the specs + * ahead of it took. + * + * So the freshness stamp is refreshed immediately before the journey acts. It + * is not a bypass and it does not weaken the route: the route still runs its + * gate, the session still has to carry a stamp, and a session without one is + * still refused. What it simulates is the person having just proved their + * factor — which is the state the product requires and the only state in which + * this surface is usable at all. + * + * Seeded through `pg`, the same transport `global-setup.ts` and + * `vault-fixture.ts` use, because there is no product endpoint that says "I + * proved a factor" without a real factor to prove. + */ +import pg from "pg"; + +import { E2E_GUARDIAN } from "./global-setup"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[managed-profile-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +/** + * Make the guardian's step-up fresh, and clear anything an earlier run left. + * + * Idempotent, and safe to call from a `beforeEach`: both statements are + * unconditional writes scoped to this one fixture account. + */ +export async function refreshGuardianStepUp(): Promise { + const pool = connect(); + try { + const stamped = await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_GUARDIAN.username], + ); + // A zero-row update means the jar this journey runs under points at a + // session that is not there, and every act below would be refused for a + // reason that has nothing to do with what it tests. Fail loudly here. + if ((stamped.rowCount ?? 0) === 0) { + throw new Error( + `[managed-profile-fixture] no session for ${E2E_GUARDIAN.username}`, + ); + } + // And the factor itself, in case a spec ran before global setup stamped it. + await pool.query( + `UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW()) + WHERE username = $1`, + [E2E_GUARDIAN.username], + ); + // The guardian-invitation bucket, for the same reason `global-setup.ts` + // clears the auth bucket between its own logins: the ceiling is ten + // invitations an hour per caller (and ten creations), and this journey spends + // one of each every time it + // runs. On CI that never matters; locally, the eleventh run inside an hour + // is answered by the product's own 429 rather than by what the test is + // about, and the failure reads as a broken invitation form. Only this one + // fixture account's bucket is touched. + await pool.query( + `DELETE FROM rate_limits + WHERE key IN ( + 'managed-profile:guardian-invite:' || + (SELECT id FROM users WHERE username = $1), + 'managed-profile:create:' || + (SELECT id FROM users WHERE username = $1) + )`, + [E2E_GUARDIAN.username], + ); + } finally { + await pool.end(); + } +} + +/** + * Remove every managed profile this account looks after. + * + * A managed profile is a real account row, so a journey that created one and + * left it behind makes the next run's roster assertions read the wrong record. + * Deleting the profile takes its grants with it. + */ +export async function clearGuardianProfiles(): Promise { + const pool = connect(); + try { + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [E2E_GUARDIAN.username], + ); + } finally { + await pool.end(); + } +} diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 36d6b7656..9b075c908 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -9,6 +9,7 @@ export { E2E_USER, E2E_OWNER, + E2E_GUARDIAN, E2E_SCOPE_DELEGATE, E2E_SCOPE_RECORDS, E2E_LEVEL_RECORDS, @@ -22,4 +23,5 @@ export { FENCE_STORAGE_STATE_PATH, FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, + GUARDIAN_STORAGE_STATE_PATH, } from "./global-setup"; diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index a5eb657ae..116fd643e 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -3,9 +3,15 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { E2E_LEVEL_RECORDS, + E2E_SCOPE_DELEGATE, E2E_SCOPE_RECORDS, + GUARDIAN_STORAGE_STATE_PATH, SCOPE_DELEGATE_STORAGE_STATE_PATH, } from "./setup/test-helpers"; +import { + clearGuardianProfiles, + refreshGuardianStepUp, +} from "./setup/managed-profile-fixture"; const DOMAIN_READS = { measurements: "/api/measurements", @@ -522,3 +528,160 @@ test.describe.serial("scoped sharing browser journeys", () => { await leaveRecord(page); }); }); + +/** + * [J3-managed-profile-lifecycle] — the half of the release goal that had no + * browser at all until this plan. + * + * The routes have shipped with integration coverage since the profile family + * landed, and nothing under `src/components` or `src/app` called any of them. + * These two tests are the person's side of it: an adult with no managed profile + * creates one, finds it in the switcher, enters it, leaves it, invites a second + * Guardian and is told — before acting rather than by a refusal afterwards — + * that the invitation does not count until it is accepted. + * + * Its own session jar and its own account, because this is the only account in + * the suite with a second factor enrolled: every route here resolves + * `requireFreshMfa`, which refuses an unenrolled account outright. + */ +test.describe.serial("managed profile browser journey", () => { + test.use({ storageState: GUARDIAN_STORAGE_STATE_PATH }); + + const PROFILE_NAME = "E2E managed record"; + + test.beforeEach(async () => { + await clearGuardianProfiles(); + await refreshGuardianStepUp(); + }); + + test.afterAll(async () => { + await clearGuardianProfiles(); + }); + + async function createProfile(page: Page): Promise { + await page.goto("/settings/access"); + const card = page.locator('[data-slot="managed-profile-card"]'); + await expect(card).toBeVisible(); + await card.locator('[data-slot="managed-profile-name"]').fill(PROFILE_NAME); + await card.locator('[data-slot="managed-profile-create-submit"]').click(); + await expect( + card.locator('[data-slot="managed-profile-created"]'), + ).toBeVisible(); + } + + test("[J3-managed-profile-lifecycle] creates a record with no login and reaches it from the switcher", async ({ + page, + }) => { + await createProfile(page); + + // The row appears without a reload, which is the account-payload + // invalidation doing its job rather than a lucky refetch. + const row = page.locator('[data-slot="managed-profile-row"]'); + await expect(row).toHaveCount(1); + const profileId = await row.getAttribute("data-managed-profile-id"); + expect(profileId).not.toBeNull(); + + // The switcher lists it as a managed record. Asserted on the stable + // attribute rather than on the label, so the copy stays free to change. + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-id="${profileId}"]`, + ); + await expect(entry).toHaveCount(1); + await expect(entry).toHaveAttribute("data-record-kind", "managed"); + await entry.click(); + + // Inside it, the banner says which kind of record this is. + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toBeVisible(); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + // The WORDING, not only the attribute, and this is the one place in the + // suite where pinning copy is the right call rather than the lazy one: the + // claim is that the banner NAMES it as a managed record, and the attribute + // is not what a person reads. `recordKindLabel`'s ternary chooses between + // two sentences that mean different things, and inverting it left every + // attribute assertion green while telling a Guardian they were inside an + // ordinary shared record. The pure function is pinned in + // `src/components/layout/__tests__/shared-record-banner-label.test.tsx`; + // this line proves the rendered banner really calls it. + await expect( + page.locator('[data-slot="shared-record-banner-context"]'), + ).toContainText("Managed profile"); + + // And back out, with the record still on the list. + // + // Leaving ends in a full document reload, and a `goto` issued while that + // reload is still in flight is aborted by Chromium — `net::ERR_ABORTED`, + // reproducibly at CI's worker count and not at one. The banner being gone + // is satisfied by the pre-reload DOM, so it cannot be the thing waited on. + // Retrying the navigation is the honest fix: the claim is that the record + // survives the round trip, not that the first navigation attempt wins a + // race with the app's own. + await leaveRecord(page); + await expect(async () => { + await page.goto("/settings/access"); + await expect( + page.locator( + `[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`, + ), + ).toHaveCount(1); + }).toPass({ timeout: 15_000 }); + }); + + test("[J3-managed-profile-lifecycle] states the last-guardian floor before an invitation is accepted", async ({ + page, + }) => { + await createProfile(page); + + const guardians = page.locator('[data-slot="managed-profile-guardians"]'); + await expect(guardians).toBeVisible(); + + // One guardian, and it is the creator: no access here can be ended, and + // the card says so rather than offering a control that would 404. + await expect( + guardians.locator('[data-slot="managed-profile-guardian"]'), + ).toHaveCount(1); + await expect( + guardians.locator('[data-slot="managed-profile-guardian-remove"]'), + ).toHaveCount(0); + await expect( + guardians.locator('[data-slot="managed-profile-sole-guardian"]'), + ).toBeVisible(); + + // Invite a second guardian. + await guardians + .locator('[data-slot="managed-profile-guardian-identifier"]') + .fill(E2E_SCOPE_DELEGATE.username); + await guardians + .locator('[data-slot="managed-profile-guardian-submit"]') + .click(); + await expect( + guardians.locator('[data-slot="managed-profile-guardian-invited"]'), + ).toBeVisible(); + + // The roster shows them, and shows them as PENDING — which is the release + // goal's sentence in the only form a browser can falsify: the invitation + // reached the server, came back as a grant that confers nothing yet, and + // the panel says which of the two states it is in. Delete the invitation + // feature and this line goes red. + await expect( + guardians.locator( + '[data-slot="managed-profile-guardian"][data-guardian-state="PENDING"]', + ), + ).toHaveCount(1); + + // And the floor is unchanged BECAUSE of that state: the sole-guardian + // sentence is still on screen with a second person already invited. + // + // The `managed-profile-pending-note` line that used to sit here was + // removed rather than kept. That node renders unconditionally inside the + // invite form, so the assertion passed with the invitation feature deleted + // — it proved the copy exists, which is a claim about a string and not + // about anything the journey did. The note's own leg lives in + // `managed-profile-affordances.test.tsx`, where deleting the node makes it + // fail; here it would only have been a second look at the same string. + await expect( + guardians.locator('[data-slot="managed-profile-sole-guardian"]'), + ).toBeVisible(); + }); +}); diff --git a/messages/de.json b/messages/de.json index b928bc2ac..f3cf1a8af 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9642,6 +9642,61 @@ "kindManaged": "Verwaltetes Profil", "open": "Akte von {name} öffnen" }, + "managed": { + "title": "Profile, die du betreust", + "description": "Gesundheitsakten ohne eigenen Login.", + "explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet — ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.", + "nameLabel": "Name", + "namePlaceholder": "Der Name, an dem du diese Akte erkennst", + "nameHint": "Bis zu 80 Zeichen. Wird überall dort angezeigt, wo diese Akte auftaucht.", + "nameRequired": "Gib der Akte einen Namen.", + "nameTooLong": "Dieser Name ist länger als 80 Zeichen.", + "dobLabel": "Geburtsdatum (optional)", + "dobHint": "Lass das Feld leer, wenn du keines hinterlegen willst. Aus einem Jahrgang wird nichts abgeleitet.", + "localeLabel": "Sprache dieser Akte", + "localeHint": "Die Sprache für Texte, die in diese Akte geschrieben werden. Deine eigenen Ansichten bleiben in deiner Sprache.", + "timezoneHint": "Die Zeitzone, in der die Tage dieser Akte gezählt werden.", + "timezoneInvalid": "Diese Zeitzone kennt der Browser nicht.", + "create": "Profil anlegen", + "creating": "Wird angelegt…", + "created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.", + "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", + "errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.", + "errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.", + "errorFailed": "Das Profil konnte nicht angelegt werden.", + "delete": "Profil löschen", + "deleteTitle": "{name} löschen?", + "deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.", + "deleteGuardiansOne": "{count} Person betreut diese Akte derzeit und verliert sie.", + "deleteGuardiansFew": "{count} Personen betreuen diese Akte derzeit und verlieren sie.", + "deleteGuardiansOther": "{count} Personen betreuen diese Akte derzeit und verlieren sie.", + "deleteFloorNote": "Das ist auch der Ausweg, wenn du die letzte betreuende Person bist: Du darfst die Akte beenden, aber nicht ohne Betreuung zurücklassen.", + "deleteConfirm": "Profil löschen", + "guardiansTitle": "Wer sich um {name} kümmert", + "guardianYou": "(du)", + "guardianSince": "Betreut diese Akte seit {date}", + "guardianPending": "Eingeladen am {date}, noch nicht angenommen", + "guardianRemove": "Zugriff beenden", + "guardianRemoveTitle": "Zugriff von {name} beenden?", + "guardianRemoveBody": "{name} kann {profile} dann nicht mehr öffnen. Was diese Person eingetragen hat, bleibt in der Akte.", + "guardianWithdrawBody": "Die Einladung an {name} wird zurückgezogen. Die Person hat {profile} nie geöffnet, in der Akte ändert sich nichts.", + "guardianRemoveConfirm": "Zugriff beenden", + "soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert — hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.", + "guardianInviteLabel": "Weitere betreuende Person einladen", + "guardianInvite": "Einladung senden", + "guardianInviting": "Wird gesendet…", + "guardianInvited": "Einladung an {name} gesendet.", + "guardianExpiresHint": "Optional. Der Zugriff endet am Ende dieses Tages in deiner Zeitzone.", + "guardianPendingNote": "Eine eingeladene Person zählt erst, wenn sie annimmt. Bis dahin bist du weiterhin die letzte, und die Rückgabe deines eigenen Zugriffs wird weiterhin abgelehnt.", + "guardianErrorDuplicate": "Dieses Konto hat bereits Zugriff auf diese Akte.", + "guardianErrorManagedInvitee": "Ein verwaltetes Profil kann keine andere Akte betreuen. Lade ein Konto ein, das sich anmeldet.", + "guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.", + "guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.", + "guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.", + "errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.", + "rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.", + "errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut." + }, "toast": { "savedTo": "In der Akte von {name} gespeichert." }, @@ -9786,7 +9841,10 @@ "alreadyEnded": "Dieser Zugriff ist bereits beendet.", "notFound": "Diesen Zugriff gibt es nicht mehr.", "revokeFailed": "Der Zugriff konnte nicht beendet werden.", - "renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden." + "renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden.", + "profileNotFound": "Dieses Profil gibt es nicht mehr — es wurde vielleicht schon gelöscht, oder dein Zugriff darauf ist beendet.", + "deleteProfileFailed": "Das Profil konnte nicht gelöscht werden.", + "removeGuardianFailed": "Dieser Zugriff konnte nicht beendet werden." }, "anamnesis": { "relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte." diff --git a/messages/en.json b/messages/en.json index 208915163..9969e7de4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9642,6 +9642,61 @@ "kindManaged": "Managed profile", "open": "Open {name}'s record" }, + "managed": { + "title": "Records you look after", + "description": "Health records with no login of their own.", + "explainer": "A managed profile is a health record for somebody who does not sign in themselves — a child, or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.", + "nameLabel": "Name", + "namePlaceholder": "The name you will recognise this record by", + "nameHint": "Up to 80 characters. Shown wherever this record appears.", + "nameRequired": "Give the record a name.", + "nameTooLong": "That name is longer than 80 characters.", + "dobLabel": "Date of birth (optional)", + "dobHint": "Leave it empty if you would rather not record one. Nothing is guessed from a year.", + "localeLabel": "Language of this record", + "localeHint": "The language used for text written into this record. Your own screens stay in your language.", + "timezoneHint": "The zone this record's days are measured in.", + "timezoneInvalid": "This browser does not know that timezone.", + "create": "Create profile", + "creating": "Creating…", + "created": "{name} is set up. You can switch into that record from the account menu.", + "errorStepUp": "Confirm your second factor, then try again.", + "errorOffline": "That did not reach the server. Nothing has changed — check your connection and try again.", + "errorInvalid": "The server refused those details. Check the name, the date and the timezone.", + "errorFailed": "The profile could not be created.", + "delete": "Delete profile", + "deleteTitle": "Delete {name}?", + "deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.", + "deleteGuardiansOne": "{count} person looks after this record today and will lose it.", + "deleteGuardiansFew": "{count} people look after this record today and will lose it.", + "deleteGuardiansOther": "{count} people look after this record today and will lose it.", + "deleteFloorNote": "This is also the way out when you are the last guardian: you may end the record, but you may not leave it with nobody looking after it.", + "deleteConfirm": "Delete profile", + "guardiansTitle": "Who looks after {name}", + "guardianYou": "(you)", + "guardianSince": "Looking after this record since {date}", + "guardianPending": "Invited {date}, not accepted yet", + "guardianRemove": "End access", + "guardianRemoveTitle": "End {name}'s access?", + "guardianRemoveBody": "{name} stops being able to open {profile}. Anything they entered stays in the record.", + "guardianWithdrawBody": "The invitation to {name} is withdrawn. They never opened {profile}, so nothing changes in the record.", + "guardianRemoveConfirm": "End access", + "soleGuardian": "You are the only person looking after {name}, so there is no access here you can end. Invite somebody and wait for them to accept, hand your own access back under “Records I can open”, or delete the profile.", + "guardianInviteLabel": "Invite another guardian", + "guardianInvite": "Send invitation", + "guardianInviting": "Sending…", + "guardianInvited": "Invitation sent to {name}.", + "guardianExpiresHint": "Optional. Access ends at the end of that day in your timezone.", + "guardianPendingNote": "An invited guardian counts only once they accept. Until then you are still the last one, and handing your own access back is still refused.", + "guardianErrorDuplicate": "That account already has access to this record.", + "guardianErrorManagedInvitee": "A managed profile cannot look after another record. Invite an account that signs in.", + "guardianErrorNoAccount": "No account on this instance carries that name or e-mail.", + "guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.", + "guardianErrorFailed": "The invitation could not be sent.", + "errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.", + "rosterLoadError": "Could not load who looks after this record.", + "errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again." + }, "toast": { "savedTo": "Saved to {name}'s record." }, @@ -9786,7 +9841,10 @@ "alreadyEnded": "That access has already ended.", "notFound": "That access is no longer there.", "revokeFailed": "The access could not be ended.", - "renounceFailed": "The access could not be handed back." + "renounceFailed": "The access could not be handed back.", + "profileNotFound": "That profile is not there any more — it may already have been deleted, or your access to it may have ended.", + "deleteProfileFailed": "The profile could not be deleted.", + "removeGuardianFailed": "That access could not be ended." }, "anamnesis": { "relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here." diff --git a/messages/es.json b/messages/es.json index 8cc2cb450..a800a3727 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9642,6 +9642,61 @@ "kindManaged": "Perfil administrado", "open": "Abrir el historial de {name}" }, + "managed": { + "title": "Perfiles de los que te ocupas", + "description": "Historiales de salud sin inicio de sesión propio.", + "explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión — una criatura, o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.", + "nameLabel": "Nombre", + "namePlaceholder": "El nombre con el que reconocerás este historial", + "nameHint": "Hasta 80 caracteres. Se muestra allá donde aparezca este historial.", + "nameRequired": "Dale un nombre al historial.", + "nameTooLong": "Ese nombre tiene más de 80 caracteres.", + "dobLabel": "Fecha de nacimiento (opcional)", + "dobHint": "Déjala vacía si prefieres no registrarla. No se deduce nada a partir de un año.", + "localeLabel": "Idioma de este historial", + "localeHint": "El idioma de los textos que se escriben en este historial. Tus propias pantallas siguen en tu idioma.", + "timezoneHint": "La zona en la que se miden los días de este historial.", + "timezoneInvalid": "Este navegador no conoce esa zona horaria.", + "create": "Crear perfil", + "creating": "Creando…", + "created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.", + "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", + "errorOffline": "Esto no llegó al servidor. No ha cambiado nada — revisa tu conexión y vuelve a intentarlo.", + "errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.", + "errorFailed": "No se pudo crear el perfil.", + "delete": "Eliminar perfil", + "deleteTitle": "¿Eliminar {name}?", + "deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.", + "deleteGuardiansOne": "{count} persona se ocupa hoy de este historial y lo perderá.", + "deleteGuardiansFew": "{count} personas se ocupan hoy de este historial y lo perderán.", + "deleteGuardiansOther": "{count} personas se ocupan hoy de este historial y lo perderán.", + "deleteFloorNote": "También es la salida cuando eres la última persona responsable: puedes terminar el historial, pero no dejarlo sin nadie que se ocupe de él.", + "deleteConfirm": "Eliminar perfil", + "guardiansTitle": "Quién se ocupa de {name}", + "guardianYou": "(tú)", + "guardianSince": "Se ocupa de este historial desde el {date}", + "guardianPending": "Invitada el {date}, aún sin aceptar", + "guardianRemove": "Terminar acceso", + "guardianRemoveTitle": "¿Terminar el acceso de {name}?", + "guardianRemoveBody": "{name} dejará de poder abrir {profile}. Lo que haya registrado se queda en el historial.", + "guardianWithdrawBody": "Se retira la invitación a {name}. Nunca abrió {profile}, así que en el historial no cambia nada.", + "guardianRemoveConfirm": "Terminar acceso", + "soleGuardian": "Eres la única persona que se ocupa de {name}, así que aquí no hay ningún acceso que puedas terminar. Invita a alguien y espera a que acepte, devuelve tu propio acceso en «Historiales que puedo abrir», o elimina el perfil.", + "guardianInviteLabel": "Invitar a otra persona responsable", + "guardianInvite": "Enviar invitación", + "guardianInviting": "Enviando…", + "guardianInvited": "Invitación enviada a {name}.", + "guardianExpiresHint": "Opcional. El acceso termina al final de ese día en tu zona horaria.", + "guardianPendingNote": "Una persona invitada solo cuenta cuando acepta. Hasta entonces sigues siendo la última, y devolver tu propio acceso se sigue rechazando.", + "guardianErrorDuplicate": "Esa cuenta ya tiene acceso a este historial.", + "guardianErrorManagedInvitee": "Un perfil administrado no puede ocuparse de otro historial. Invita a una cuenta que inicie sesión.", + "guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.", + "guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.", + "guardianErrorFailed": "No se pudo enviar la invitación.", + "errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.", + "rosterLoadError": "No se pudo cargar quién se ocupa de este historial.", + "errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo." + }, "toast": { "savedTo": "Guardado en el historial de {name}." }, @@ -9786,7 +9841,10 @@ "alreadyEnded": "Ese acceso ya ha terminado.", "notFound": "Ese acceso ya no existe.", "revokeFailed": "No se pudo terminar el acceso.", - "renounceFailed": "No se pudo devolver el acceso." + "renounceFailed": "No se pudo devolver el acceso.", + "profileNotFound": "Ese perfil ya no está — puede que se haya eliminado, o que tu acceso haya terminado.", + "deleteProfileFailed": "No se pudo eliminar el perfil.", + "removeGuardianFailed": "No se pudo terminar ese acceso." }, "anamnesis": { "relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí." diff --git a/messages/fr.json b/messages/fr.json index 6668aba8d..b14279d03 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9642,6 +9642,61 @@ "kindManaged": "Profil géré", "open": "Ouvrir le dossier de {name}" }, + "managed": { + "title": "Profils dont vous vous occupez", + "description": "Dossiers de santé sans identifiants propres.", + "explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même — un enfant, ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.", + "nameLabel": "Nom", + "namePlaceholder": "Le nom sous lequel vous reconnaîtrez ce dossier", + "nameHint": "Jusqu'à 80 caractères. Affiché partout où ce dossier apparaît.", + "nameRequired": "Donnez un nom au dossier.", + "nameTooLong": "Ce nom dépasse 80 caractères.", + "dobLabel": "Date de naissance (facultatif)", + "dobHint": "Laissez le champ vide si vous préférez ne pas en enregistrer. Rien n'est déduit d'une année.", + "localeLabel": "Langue de ce dossier", + "localeHint": "La langue des textes écrits dans ce dossier. Vos propres écrans restent dans votre langue.", + "timezoneHint": "Le fuseau dans lequel les journées de ce dossier sont comptées.", + "timezoneInvalid": "Ce navigateur ne connaît pas ce fuseau horaire.", + "create": "Créer le profil", + "creating": "Création…", + "created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.", + "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", + "errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.", + "errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.", + "errorFailed": "Le profil n'a pas pu être créé.", + "delete": "Supprimer le profil", + "deleteTitle": "Supprimer {name} ?", + "deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.", + "deleteGuardiansOne": "{count} personne s'occupe aujourd'hui de ce dossier et le perdra.", + "deleteGuardiansFew": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.", + "deleteGuardiansOther": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.", + "deleteFloorNote": "C'est aussi l'issue lorsque vous êtes la dernière personne responsable : vous pouvez mettre fin au dossier, mais pas le laisser sans personne.", + "deleteConfirm": "Supprimer le profil", + "guardiansTitle": "Qui s'occupe de {name}", + "guardianYou": "(vous)", + "guardianSince": "S'occupe de ce dossier depuis le {date}", + "guardianPending": "Invitée le {date}, pas encore acceptée", + "guardianRemove": "Mettre fin à l'accès", + "guardianRemoveTitle": "Mettre fin à l'accès de {name} ?", + "guardianRemoveBody": "{name} ne pourra plus ouvrir {profile}. Ce qu'elle a saisi reste dans le dossier.", + "guardianWithdrawBody": "L'invitation adressée à {name} est retirée. Cette personne n'a jamais ouvert {profile}, rien ne change dans le dossier.", + "guardianRemoveConfirm": "Mettre fin à l'accès", + "soleGuardian": "Vous êtes la seule personne à vous occuper de {name} : il n'y a donc ici aucun accès auquel mettre fin. Invitez quelqu'un et attendez son acceptation, rendez votre propre accès sous « Dossiers que je peux ouvrir », ou supprimez le profil.", + "guardianInviteLabel": "Inviter une autre personne responsable", + "guardianInvite": "Envoyer l'invitation", + "guardianInviting": "Envoi…", + "guardianInvited": "Invitation envoyée à {name}.", + "guardianExpiresHint": "Facultatif. L'accès prend fin à la fin de cette journée dans votre fuseau horaire.", + "guardianPendingNote": "Une personne invitée ne compte qu'une fois qu'elle a accepté. D'ici là vous êtes toujours la dernière, et rendre votre propre accès reste refusé.", + "guardianErrorDuplicate": "Ce compte a déjà accès à ce dossier.", + "guardianErrorManagedInvitee": "Un profil géré ne peut pas s'occuper d'un autre dossier. Invitez un compte qui se connecte.", + "guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.", + "guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.", + "guardianErrorFailed": "L'invitation n'a pas pu être envoyée.", + "errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.", + "rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.", + "errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez." + }, "toast": { "savedTo": "Enregistré dans le dossier de {name}." }, @@ -9786,7 +9841,10 @@ "alreadyEnded": "Cet accès a déjà pris fin.", "notFound": "Cet accès n'existe plus.", "revokeFailed": "L'accès n'a pas pu être terminé.", - "renounceFailed": "L'accès n'a pas pu être rendu." + "renounceFailed": "L'accès n'a pas pu être rendu.", + "profileNotFound": "Ce profil n'existe plus — il a peut-être été supprimé, ou votre accès a pris fin.", + "deleteProfileFailed": "Le profil n'a pas pu être supprimé.", + "removeGuardianFailed": "Cet accès n'a pas pu prendre fin." }, "anamnesis": { "relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici." diff --git a/messages/it.json b/messages/it.json index e922a3689..72a8faeb1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9642,6 +9642,61 @@ "kindManaged": "Profilo gestito", "open": "Apri la cartella di {name}" }, + "managed": { + "title": "Profili di cui ti occupi", + "description": "Cartelle sanitarie senza credenziali proprie.", + "explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé — un bambino, o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.", + "nameLabel": "Nome", + "namePlaceholder": "Il nome con cui riconoscerai questa cartella", + "nameHint": "Fino a 80 caratteri. Compare ovunque appaia questa cartella.", + "nameRequired": "Dai un nome alla cartella.", + "nameTooLong": "Questo nome supera gli 80 caratteri.", + "dobLabel": "Data di nascita (facoltativa)", + "dobHint": "Lascia il campo vuoto se preferisci non registrarla. Da un anno di nascita non si deduce nulla.", + "localeLabel": "Lingua di questa cartella", + "localeHint": "La lingua dei testi scritti in questa cartella. Le tue schermate restano nella tua lingua.", + "timezoneHint": "Il fuso in cui si contano le giornate di questa cartella.", + "timezoneInvalid": "Questo browser non conosce quel fuso orario.", + "create": "Crea profilo", + "creating": "Creazione in corso…", + "created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.", + "errorStepUp": "Conferma il tuo secondo fattore e riprova.", + "errorOffline": "Non è arrivato al server. Non è cambiato nulla — controlla la connessione e riprova.", + "errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.", + "errorFailed": "Non è stato possibile creare il profilo.", + "delete": "Elimina profilo", + "deleteTitle": "Eliminare {name}?", + "deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.", + "deleteGuardiansOne": "{count} persona si occupa oggi di questa cartella e la perderà.", + "deleteGuardiansFew": "{count} persone si occupano oggi di questa cartella e la perderanno.", + "deleteGuardiansOther": "{count} persone si occupano oggi di questa cartella e la perderanno.", + "deleteFloorNote": "È anche la via d'uscita quando sei l'ultima persona responsabile: puoi chiudere la cartella, ma non lasciarla senza nessuno.", + "deleteConfirm": "Elimina profilo", + "guardiansTitle": "Chi si occupa di {name}", + "guardianYou": "(tu)", + "guardianSince": "Si occupa di questa cartella dal {date}", + "guardianPending": "Invitata il {date}, non ancora accettata", + "guardianRemove": "Termina accesso", + "guardianRemoveTitle": "Terminare l'accesso di {name}?", + "guardianRemoveBody": "{name} non potrà più aprire {profile}. Quello che ha inserito resta nella cartella.", + "guardianWithdrawBody": "L'invito a {name} viene ritirato. Non ha mai aperto {profile}, quindi nella cartella non cambia nulla.", + "guardianRemoveConfirm": "Termina accesso", + "soleGuardian": "Sei l'unica persona che si occupa di {name}, quindi qui non c'è nessun accesso da terminare. Invita qualcuno e aspetta che accetti, restituisci il tuo accesso in «Cartelle che posso aprire», oppure elimina il profilo.", + "guardianInviteLabel": "Invita un'altra persona responsabile", + "guardianInvite": "Invia invito", + "guardianInviting": "Invio in corso…", + "guardianInvited": "Invito inviato a {name}.", + "guardianExpiresHint": "Facoltativo. L'accesso termina alla fine di quel giorno nel tuo fuso orario.", + "guardianPendingNote": "Una persona invitata conta solo quando accetta. Fino ad allora sei ancora l'ultima, e restituire il tuo accesso viene comunque rifiutato.", + "guardianErrorDuplicate": "Quell'account ha già accesso a questa cartella.", + "guardianErrorManagedInvitee": "Un profilo gestito non può occuparsi di un'altra cartella. Invita un account che accede da sé.", + "guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.", + "guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.", + "guardianErrorFailed": "Non è stato possibile inviare l'invito.", + "errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.", + "rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.", + "errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova." + }, "toast": { "savedTo": "Salvato nella cartella di {name}." }, @@ -9786,7 +9841,10 @@ "alreadyEnded": "Questo accesso è già terminato.", "notFound": "Questo accesso non esiste più.", "revokeFailed": "Non è stato possibile terminare l'accesso.", - "renounceFailed": "Non è stato possibile restituire l'accesso." + "renounceFailed": "Non è stato possibile restituire l'accesso.", + "profileNotFound": "Quel profilo non c'è più — potrebbe essere già stato eliminato, o il tuo accesso è terminato.", + "deleteProfileFailed": "Non è stato possibile eliminare il profilo.", + "removeGuardianFailed": "Non è stato possibile terminare quell'accesso." }, "anamnesis": { "relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui." diff --git a/messages/pl.json b/messages/pl.json index 1866ce06b..ddd1b58b4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9642,6 +9642,61 @@ "kindManaged": "Zarządzany profil", "open": "Otwórz kartę użytkownika {name}" }, + "managed": { + "title": "Profile, którymi się opiekujesz", + "description": "Karty zdrowia bez własnego logowania.", + "explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje — dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.", + "nameLabel": "Nazwa", + "namePlaceholder": "Nazwa, po której rozpoznasz tę kartę", + "nameHint": "Do 80 znaków. Wyświetla się wszędzie tam, gdzie pojawia się ta karta.", + "nameRequired": "Nadaj karcie nazwę.", + "nameTooLong": "Ta nazwa ma więcej niż 80 znaków.", + "dobLabel": "Data urodzenia (opcjonalnie)", + "dobHint": "Zostaw puste, jeśli wolisz jej nie zapisywać. Z samego rocznika nic nie jest wyliczane.", + "localeLabel": "Język tej karty", + "localeHint": "Język tekstów zapisywanych w tej karcie. Twoje własne widoki pozostają w Twoim języku.", + "timezoneHint": "Strefa, w której liczone są dni tej karty.", + "timezoneInvalid": "Ta przeglądarka nie zna takiej strefy czasowej.", + "create": "Utwórz profil", + "creating": "Tworzenie…", + "created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.", + "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", + "errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.", + "errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.", + "errorFailed": "Nie udało się utworzyć profilu.", + "delete": "Usuń profil", + "deleteTitle": "Usunąć {name}?", + "deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.", + "deleteGuardiansOne": "{count} osoba opiekuje się dziś tą kartą i ją straci.", + "deleteGuardiansFew": "{count} osoby opiekują się dziś tą kartą i ją stracą.", + "deleteGuardiansOther": "{count} osób opiekuje się dziś tą kartą i ją straci.", + "deleteFloorNote": "To także wyjście, gdy jesteś ostatnią osobą opiekującą się profilem: możesz zamknąć kartę, ale nie zostawić jej bez opieki.", + "deleteConfirm": "Usuń profil", + "guardiansTitle": "Kto opiekuje się kartą {name}", + "guardianYou": "(Ty)", + "guardianSince": "Opiekuje się tą kartą od {date}", + "guardianPending": "Zaproszenie z {date}, jeszcze nieprzyjęte", + "guardianRemove": "Zakończ dostęp", + "guardianRemoveTitle": "Zakończyć dostęp osoby {name}?", + "guardianRemoveBody": "{name} nie będzie już mogła otworzyć karty {profile}. To, co zapisała, zostaje w karcie.", + "guardianWithdrawBody": "Zaproszenie dla osoby {name} zostaje wycofane. Nigdy nie otworzyła karty {profile}, więc w karcie nic się nie zmienia.", + "guardianRemoveConfirm": "Zakończ dostęp", + "soleGuardian": "Jesteś jedyną osobą, która opiekuje się kartą {name}, więc nie ma tu dostępu, który mógłbyś zakończyć. Zaproś kogoś i poczekaj na przyjęcie, oddaj własny dostęp w sekcji „Karty, które mogę otworzyć” albo usuń profil.", + "guardianInviteLabel": "Zaproś kolejną osobę opiekującą się profilem", + "guardianInvite": "Wyślij zaproszenie", + "guardianInviting": "Wysyłanie…", + "guardianInvited": "Zaproszenie wysłane do osoby {name}.", + "guardianExpiresHint": "Opcjonalnie. Dostęp kończy się z końcem tego dnia w Twojej strefie czasowej.", + "guardianPendingNote": "Zaproszona osoba liczy się dopiero po przyjęciu zaproszenia. Do tego czasu wciąż jesteś ostatnia, a oddanie własnego dostępu nadal będzie odrzucane.", + "guardianErrorDuplicate": "To konto ma już dostęp do tej karty.", + "guardianErrorManagedInvitee": "Zarządzany profil nie może opiekować się inną kartą. Zaproś konto, które samo się loguje.", + "guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.", + "guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.", + "guardianErrorFailed": "Nie udało się wysłać zaproszenia.", + "errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.", + "rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.", + "errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie." + }, "toast": { "savedTo": "Zapisano w karcie użytkownika {name}." }, @@ -9786,7 +9841,10 @@ "alreadyEnded": "Ten dostęp już się zakończył.", "notFound": "Tego dostępu już nie ma.", "revokeFailed": "Nie udało się zakończyć dostępu.", - "renounceFailed": "Nie udało się zwrócić dostępu." + "renounceFailed": "Nie udało się zwrócić dostępu.", + "profileNotFound": "Tego profilu już nie ma — mógł zostać usunięty albo Twój dostęp do niego się zakończył.", + "deleteProfileFailed": "Nie udało się usunąć profilu.", + "removeGuardianFailed": "Nie udało się zakończyć tego dostępu." }, "anamnesis": { "relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany." diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 447d71f49..4613b0c9d 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -241,6 +241,23 @@ "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." } ] + }, + { + "name": "Operational windows and cookie flags", + "description": "Values the product states back to a user or that decide whether a session cookie is accepted. Optional: each has a documented default. They are listed so `pnpm check-env` reports them, and so the compose whitelist and the example files can be held in step with this manifest.", + "required": false, + "variables": [ + { + "name": "AUDIT_LOG_RETENTION_DAYS", + "purpose": "How long audit rows survive, in days (365 by default). Published, not merely enforced: the shared-access activity feed and the dialog that ends somebody's access both state the number back to the user.", + "example": "365" + }, + { + "name": "SESSION_COOKIE_SECURE", + "purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.", + "example": "false" + } + ] } ] } diff --git a/src/__tests__/manage-activity-inventory.test.ts b/src/__tests__/manage-activity-inventory.test.ts index 626d32e58..6f0b765ec 100644 --- a/src/__tests__/manage-activity-inventory.test.ts +++ b/src/__tests__/manage-activity-inventory.test.ts @@ -110,6 +110,18 @@ function routeFiles(dir: string): string[] { interface ModuleFacts { /** Does the module reach the record resolver at ANY level. */ delegatesRecord: boolean; + /** + * Every level the module passes to `requireRecordAuth`, in source order. + * + * Recorded so the inclusion rule's LEVEL axis has something that can + * falsify it. `delegatesRecord` used to be set only for `"manage"`, and the + * legs below could not tell the corrected rule from the old one: every + * assertion about coverage passed either way, because a manage-only sweep + * still finds every manage action. The leg that separates them asks whether + * the walked set STRICTLY EXCEEDS the manage-only one, and it needs the + * levels to ask. + */ + recordLevels: string[]; /** * Every action the module hands to `auditLog("…")`, awaited or not. * @@ -190,12 +202,17 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts { }; let delegatesRecord = false; + const recordLevels: string[] = []; const auditActions: string[] = []; const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const name = calleeName(node.expression); - if (name === RECORD_RESOLVER) delegatesRecord = true; + if (name === RECORD_RESOLVER) { + delegatesRecord = true; + const level = firstStringArg(node); + if (level !== null) recordLevels.push(level); + } if (name === "auditLog") { const action = firstStringArg(node); if (action !== null) auditActions.push(action); @@ -205,7 +222,7 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts { }; parsed.forEachChild(visit); - return { delegatesRecord, auditActions, imports: importPaths }; + return { delegatesRecord, recordLevels, auditActions, imports: importPaths }; } /** @@ -441,6 +458,30 @@ describe("every delegated action has a sentence of its own", () => { expect(actions.length).toBeGreaterThan(60); }); + it("walks strictly more modules than a manage-only sweep would", () => { + // The level axis of the inclusion rule, with something that can falsify + // it. Every other leg in this file passes under BOTH rules — a manage-only + // sweep still finds every manage action, so coverage cannot tell the two + // apart. This one can: the corrected rule is "any module that reaches + // `requireRecordAuth` at any level", so the walked set must strictly + // exceed the subset that names `manage` anywhere. + const manageOnly = delegatedModules.filter((file) => + read(file).recordLevels.includes("manage"), + ); + expect(manageOnly.length).toBeGreaterThan(0); + expect(delegatedModules.length).toBeGreaterThan(manageOnly.length); + + // A named anchor, so the claim does not rest on a count that could drift + // to equality without anybody noticing. The custom-metrics list resolves + // `requireRecordAuth("read", …)` and nothing else, so a manage-only filter + // drops it — along with the refusal breadcrumbs it and its neighbours file. + const anchor = join(API, "custom-metrics/route.ts"); + expect(read(anchor).recordLevels).toEqual(["read"]); + expect(read(anchor).recordLevels).not.toContain("manage"); + expect(delegatedModules).toContain(anchor); + expect(manageOnly).not.toContain(anchor); + }); + it("reaches the actions a route files through what it imports", () => { // Both halves of the fix, pinned against the two that were invisible. expect(actions).toContain("medication.intake.import.kickoff.denied"); diff --git a/src/__tests__/published-env-reaches-the-container.test.ts b/src/__tests__/published-env-reaches-the-container.test.ts index 1d7ca6afa..3847c4a0f 100644 --- a/src/__tests__/published-env-reaches-the-container.test.ts +++ b/src/__tests__/published-env-reaches-the-container.test.ts @@ -34,6 +34,13 @@ import { describe, expect, it } from "vitest"; * `scripts/check-env.ts` owns that question and CI runs it against * `.env.production.example`. This is the narrower one that check-env cannot * answer, because it reads a file rather than the compose whitelist. + * + * v1.37.0 — a fourth place joined the three. Both of these variables were + * documented in the two example files and whitelisted in compose, and NEITHER + * was in `scripts/env-manifest.json` — so `pnpm check-env` had never heard of + * them, and an operator running the pre-deploy check against their own + * environment was told nothing about the two settings this guard exists for. + * Three places held them in step and the fourth, the one CI runs, did not. */ const ROOT = process.cwd(); const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8"); @@ -42,6 +49,19 @@ const PROD_EXAMPLE = readFileSync( join(ROOT, ".env.production.example"), "utf8", ); +const MANIFEST = readFileSync(join(ROOT, "scripts/env-manifest.json"), "utf8"); + +/** Every variable name the manifest declares, in any group. */ +const MANIFEST_NAMES: string[] = ( + JSON.parse(MANIFEST) as { + groups: Array<{ variables: Array<{ name: string; anyOf?: string[] }> }>; + } +).groups.flatMap((group) => + group.variables.flatMap((variable) => [ + variable.name, + ...(variable.anyOf ?? []), + ]), +); /** * Variables whose value the product states back to a user. @@ -72,6 +92,11 @@ describe("a variable the product publishes reaches the container", () => { expect(COMPOSE).toContain("environment:"); expect(ENV_EXAMPLE.length).toBeGreaterThan(1000); expect(PROD_EXAMPLE.length).toBeGreaterThan(1000); + // The manifest really parsed, and really carries names. An empty list + // would make the manifest leg below a quiet pass for every entry. + expect(MANIFEST_NAMES.length).toBeGreaterThan(20); + expect(MANIFEST_NAMES).toContain("DATABASE_URL"); + expect(MANIFEST_NAMES).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE"); // And a name nothing declares is genuinely absent from all three, so the // matchers below are not passing on a substring that is everywhere. for (const file of [COMPOSE, ENV_EXAMPLE, PROD_EXAMPLE]) { @@ -96,6 +121,13 @@ describe("a variable the product publishes reaches the container", () => { it("is documented in the production example", () => { expect(PROD_EXAMPLE).toContain(name); }); + + it("is declared in the manifest, so `pnpm check-env` reports it", () => { + // The fourth place, and the one CI actually runs. A variable an + // operator is told to set, and which the pre-deploy check has never + // heard of, is documented everywhere except where it would be checked. + expect(MANIFEST_NAMES).toContain(name); + }); }); } }); diff --git a/src/__tests__/sharing-accessibility-states.test.tsx b/src/__tests__/sharing-accessibility-states.test.tsx index 907bd1a0a..e68899ec6 100644 --- a/src/__tests__/sharing-accessibility-states.test.tsx +++ b/src/__tests__/sharing-accessibility-states.test.tsx @@ -227,4 +227,47 @@ describe("the eight release journeys are enumerated", () => { expect(journey.claim.length, journey.id).toBeGreaterThan(40); } }); + + /** + * v1.37.0 — the ids that are wired to a test title, and only those. + * + * The enumeration above says eight journeys exist. It says nothing about + * whether any of them RAN, which is the same gap between "the suite is + * green" and "the suite covers this" that the rest of this file exists to + * close. Wiring all eight is the integration plan's item; this list is the + * subset already claimed by a spec, and it grows as they land. + * + * An id on this list whose spec loses the marker fails here rather than + * disappearing quietly from a run nobody counted. + */ + const WIRED_JOURNEYS: ReadonlyArray<{ id: string; spec: string }> = [ + { + id: "J3-managed-profile-lifecycle", + spec: "e2e/v137-sharing-managed-profiles.spec.ts", + }, + ]; + + it("claims only ids the enumeration actually declares", () => { + // A wiring entry naming a journey nobody enumerated would be a claim about + // nothing, and would make the leg below pass against a marker that means + // no more than the string it is. + expect(WIRED_JOURNEYS.length).toBeGreaterThan(0); + const declared = new Set(V137_E2E_JOURNEYS.map((j) => j.id)); + for (const { id } of WIRED_JOURNEYS) { + expect(declared, id).toContain(id); + } + }); + + it.each(WIRED_JOURNEYS)("$id is carried by a test title", ({ id, spec }) => { + const source = readFileSync(join(ROOT, spec), "utf8"); + // Non-zero proof: the spec really loaded and really has tests, so a path + // that stopped existing fails here rather than matching nothing quietly. + expect(source.length).toBeGreaterThan(1000); + const titles = [...testBodies(source).keys()]; + expect(titles.length).toBeGreaterThan(1); + expect( + titles.filter((title) => title.includes(`[${id}]`)).length, + `no test title in ${spec} carries [${id}]`, + ).toBeGreaterThan(0); + }); }); diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx index 881a13555..43da41195 100644 --- a/src/__tests__/sharing-recoverable-errors.test.tsx +++ b/src/__tests__/sharing-recoverable-errors.test.tsx @@ -207,6 +207,66 @@ describe("what a refused sharing action says", () => { key: "recordSharing.actionError.notFound", why: "the row is gone", }, + { + action: "deleteProfile", + err: apiError(404, "managed_profile.not_found"), + key: "recordSharing.actionError.profileNotFound", + why: "the only refusal the delete route classifies, and it covers three situations the server will not tell apart", + }, + { + action: "removeGuardian", + err: apiError(404, "managed_profile.not_found"), + key: "recordSharing.actionError.profileNotFound", + why: "the same refusal reached from the guardian-removal route", + }, + { + action: "removeGuardian", + err: apiError(409, "managed_profile.guardian.required"), + key: "recordSharing.actionError.lastGuardian", + why: "the floor, still enforced by the server after the card has hidden the control", + }, + { + action: "deleteProfile", + err: apiError(500), + key: "recordSharing.actionError.deleteProfileFailed", + why: "anything the delete route does not classify becomes the generic envelope and must not borrow a diagnosis", + }, + { + action: "deleteProfile", + err: apiError(401, "auth.stepup.required"), + key: "recordSharing.managed.errorStepUp", + why: "the step-up window lapsed while the confirm dialog was open — the likeliest refusal of the two acts that carry a gate", + }, + { + action: "removeGuardian", + err: apiError(401, "auth.stepup.required"), + key: "recordSharing.managed.errorStepUp", + why: "the same gate on the other confirmed act", + }, + { + action: "deleteProfile", + err: apiError(401, "auth.stepup.mfa_not_enrolled"), + key: "recordSharing.managed.errorMfaRequired", + why: "no second factor at all: 'confirm your second factor' is a dead end, and enrolling is the only way through", + }, + { + action: "accept", + err: apiError(401), + key: "recordSharing.actionError.acceptFailed", + why: "a 401 on a route with NO step-up gate is a lapsed session, and must not tell somebody to confirm a factor", + }, + { + action: "renounce", + err: apiError(401), + key: "recordSharing.actionError.renounceFailed", + why: "same, from the delegate's side", + }, + { + action: "removeGuardian", + err: new TypeError("Failed to fetch"), + key: "recordSharing.actionError.offline", + why: "never reached the server, so the guardian still has access and the row is unchanged", + }, { action: "accept", err: new TypeError("Failed to fetch"), diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts index 80b8a13de..ff797843c 100644 --- a/src/app/api/managed-profiles/[id]/guardians/route.ts +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import { apiHandler, MFA_STEP_UP_MAX_AGE_SECONDS, + requireCookieAuth, requireFreshMfa, } from "@/lib/api-handler"; import { @@ -14,12 +15,61 @@ import { } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { listManagedProfileGuardians } from "@/lib/managed-profiles/guardian-list"; import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle"; import { checkRateLimit } from "@/lib/rate-limit"; import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants"; type RouteParams = { params: Promise<{ id: string }> }; +/** + * Who looks after this profile. + * + * The read the panel needs before it can offer to remove anybody, and the one + * the last-Guardian floor is counted from before the click rather than + * discovered as a 409 after it. + * + * Cookie-only, like every endpoint in this family, and for the same structural + * reason: `requireCookieAuth` reads only the session cookie and never falls + * through to the Bearer branch. (Named that way rather than by the session + * helper it calls, because `session-surface-guard.test.ts` matches on the + * helper's NAME in file text and a comment naming it enrols this route in a + * list of files that resolve a session themselves. This one does not.) + * Fresh MFA is deliberately NOT required — this + * is a read, and it discloses nothing the caller cannot already read on their + * own sharing panel about a party to a grant they hold. The gate belongs to the + * acts, not to looking. + * + * An actor surface: the profile is named by the URL, and the caller acts as + * themselves. `requireRecordAuth` / `requireGuardianAuth` would make the roster + * answerable only while switched INTO the profile, which is backwards for a + * panel whose whole purpose is to work from the Guardian's own account. + */ +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + const guardians = await listManagedProfileGuardians({ + profileId: id, + callerId: user.id, + }); + // One refusal for "no such account", "not a managed profile" and "you are + // not a Guardian of it", so the route is not an enumeration oracle. + if (guardians === null) { + return apiError("Managed profile not found", 404, { + errorCode: "managed_profile.not_found", + }); + } + + annotate({ + action: { name: "managed_profile.guardian.list" }, + meta: { profile_id: id, guardian_count: guardians.length }, + }); + return apiSuccess(guardians); + }, +); + const INVITE_LIMIT = 10; const INVITE_WINDOW_MS = 60 * 60 * 1000; diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts index 964a6e0bf..d65868ebf 100644 --- a/src/app/api/managed-profiles/route.ts +++ b/src/app/api/managed-profiles/route.ts @@ -6,12 +6,30 @@ import { MFA_STEP_UP_MAX_AGE_SECONDS, requireFreshMfa, } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { createManagedProfile } from "@/lib/managed-profiles/create"; import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; import { isValidTimezone } from "@/lib/tz/format"; +/** + * The same ceiling its guardian sibling carries, and for the same reason. + * + * `POST /api/managed-profiles/{id}/guardians` has been rate-limited at ten an + * hour since it shipped; this route creates a real account row on every call + * and had none. A person cannot mistype their way into a hundred accounts, but + * a script can, and the two endpoints of one family answering differently to + * the same abuse is the kind of asymmetry nobody notices until it is used. + */ +const CREATE_LIMIT = 10; +const CREATE_WINDOW_MS = 60 * 60 * 1000; + const managedProfileSchema = z .object({ displayName: z.string().trim().min(1).max(80), @@ -32,6 +50,16 @@ const managedProfileSchema = z */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + // Before the body is read, exactly as the guardian route does it: the cheap + // refusal comes first so a flood costs a bucket lookup rather than a parse. + const rateLimit = await checkRateLimit( + `managed-profile:create:${user.id}`, + CREATE_LIMIT, + CREATE_WINDOW_MS, + ); + if (!rateLimit.allowed) { + return apiError("Too many profiles created, try again later", 429); + } const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/components/layout/__tests__/shared-record-banner-label.test.tsx b/src/components/layout/__tests__/shared-record-banner-label.test.tsx new file mode 100644 index 000000000..472d3f301 --- /dev/null +++ b/src/components/layout/__tests__/shared-record-banner-label.test.tsx @@ -0,0 +1,78 @@ +/** + * The banner names which KIND of record this is, and the two names differ. + * + * ## Why this file exists + * + * `recordKindLabel` is a two-arm ternary, and until this file nothing could + * fail it. The browser journey asserted `data-record-kind="managed"` — an + * attribute the function does not read — so inverting the arms left the whole + * suite green while the banner told a Guardian standing inside a managed + * profile that they were in an ordinary shared record. That is not a cosmetic + * difference: the two records have different rules, and the banner is the one + * line of chrome whose job is to say which one you are in. + * + * The mapping is asserted through the real EN bundle rather than against key + * names, because a key that resolves to the wrong sentence passes a key-level + * check. Both arms are named, and the two are asserted to be DIFFERENT — a + * function that returned one sentence for both would satisfy every "contains" + * assertion anybody would think to write. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { recordKindLabel } from "../shared-record-banner"; + +/** The real bundle, so the assertion is about sentences and not about keys. */ +const EN = JSON.parse( + readFileSync(join(process.cwd(), "messages/en.json"), "utf8"), +) as { + recordSharing: { lookingAfter: { kindManaged: string; kindShared: string } }; +}; + +/** The i18n `t` as the banner receives it, resolved against the real bundle. */ +const t = (key: string): string => { + const leaf = key + .split(".") + .reduce( + (node, segment) => + typeof node === "object" && node !== null + ? (node as Record)[segment] + : undefined, + EN, + ); + if (typeof leaf !== "string") throw new Error(`unresolved key: ${key}`); + return leaf; +}; + +describe("what the shared-record banner calls the record", () => { + it("has a translator that really resolves", () => { + // Positive control. A `t` that returned its own key would make both legs + // below pass on the key names while proving nothing about the sentences. + expect(t("recordSharing.lookingAfter.kindManaged")).toBe( + EN.recordSharing.lookingAfter.kindManaged, + ); + expect(EN.recordSharing.lookingAfter.kindManaged.length).toBeGreaterThan(3); + expect(EN.recordSharing.lookingAfter.kindShared.length).toBeGreaterThan(3); + }); + + it("names a managed profile as one", () => { + expect(recordKindLabel("managed", t)).toBe( + EN.recordSharing.lookingAfter.kindManaged, + ); + }); + + it("names an ordinary shared record as one", () => { + expect(recordKindLabel("shared", t)).toBe( + EN.recordSharing.lookingAfter.kindShared, + ); + }); + + it("does not give the two the same sentence", () => { + // The failure mode a pair of "contains" assertions cannot see. + expect(recordKindLabel("managed", t)).not.toBe( + recordKindLabel("shared", t), + ); + }); +}); diff --git a/src/components/layout/shared-record-banner.tsx b/src/components/layout/shared-record-banner.tsx index 9d30ce719..de3764e52 100644 --- a/src/components/layout/shared-record-banner.tsx +++ b/src/components/layout/shared-record-banner.tsx @@ -118,7 +118,17 @@ function accessLabel( : t("recordSharing.banner.readOnly"); } -function recordKindLabel( +/** + * Which kind of record this is, in the banner's own words. + * + * Exported and pure because the ternary is the whole claim and nothing could + * fail it: the browser journey asserted `data-record-kind="managed"`, which is + * the attribute this function does not read, so inverting the two arms left + * every test in the suite green while telling a Guardian they were inside an + * ordinary shared record. Two sentences that mean different things are worth + * one test. + */ +export function recordKindLabel( recordKind: ReturnType["recordKind"], t: ReturnType["t"], ): string { diff --git a/src/components/settings/access-section.tsx b/src/components/settings/access-section.tsx index 0925b34bb..bd2770182 100644 --- a/src/components/settings/access-section.tsx +++ b/src/components/settings/access-section.tsx @@ -3,6 +3,7 @@ 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 { ManagedProfileCard } from "@/components/settings/access/managed-profile-card"; import { RecordActivityCard } from "@/components/settings/access/record-activity-card"; /** @@ -15,9 +16,18 @@ import { RecordActivityCard } from "@/components/settings/access/record-activity * 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. + * 2. Records you look after — the other act, and the one that CREATES a + * record rather than opening one that already exists. + * 3. Who has access — the standing state, and the way to end it. + * 4. What I can open — the same, from the other side. + * 5. When it was opened — what actually happened. + * + * v1.37.0 put the managed-profile card second rather than in a settings area + * of its own. A managed profile is a record with no login, reachable only + * through the people who look after it — which is the same question this + * section already answers twice, from a third side. Splitting it out would + * have made "who may open which record" two half-answers on two pages, which + * is the settings-IA rule below in miniature. * * 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 @@ -35,6 +45,7 @@ export function AccessSection() { return (
+ diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx new file mode 100644 index 000000000..f75482bf7 --- /dev/null +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -0,0 +1,856 @@ +/** + * The managed-profile surface, from a static render. + * + * ## What an SSR render can prove here, and what it cannot + * + * It can prove that a control exists, that the sentence which qualifies it + * sits beside it in document order, that a list renders one row per profile, + * and — because the section is composed rather than routed — that the whole + * card is present for an owner and absent for a delegate. It CANNOT prove that + * what the form posts reaches the server: there is no click here, so the + * submit path never runs and every assertion below stays green with the form + * wired to a constant. That half belongs to `tests/integration/ + * managed-profile-surface.test.ts` (the real routes) and to the browser + * journey in `e2e/v137-sharing-managed-profiles.spec.ts`. + * + * The unit environment is `node`: no jsdom, no testing-library. The pure + * exports (`displayNameIssue`, `createManagedProfileErrorKey`, + * `managedProfilesOf`) are where the decisions live, and they are pinned + * directly for that reason. + * + * ## The reachability legs are legs, not reasoning + * + * `/settings/access` is an ACTOR surface — grant management is the one thing a + * delegate must have no reach into — and `access` classifies `personal`, so + * the prediction is that a Guardian inside a profile and an adult MANAGE + * delegate both get the refusal panel instead. That is asserted rather than + * argued: `auth-shell.tsx` and `settings-shell.tsx` decide reachability above + * every component in this file, and Plan 13 shipped a destination that was + * admitted and unreachable because the prediction was not checked. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { ApiError } from "@/lib/api/api-fetch"; +import type { AccountAccessEntry } from "@/lib/sharing/account-access-view"; +import type { GrantList } from "@/lib/queries/use-account-grants"; +import type { ManagedProfileGuardian } from "@/lib/queries/use-managed-profiles"; + +/** The account payload, as the cards under test read it. */ +const authRef: { + value: { + accounts: AccountAccessEntry[]; + active: AccountAccessEntry | null; + }; +} = { value: { accounts: [], active: null } }; + +vi.mock("@/hooks/use-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuth: () => ({ + user: { + id: "guardian", + username: "guardian", + timezone: "Europe/Berlin", + accountAccess: { + accounts: authRef.value.accounts, + active: authRef.value.active, + canSwitch: authRef.value.accounts.length > 0, + }, + }, + isLoading: false, + isAuthenticated: true, + isAuthUnknown: false, + error: null, + refetch: vi.fn(), + }), + }; +}); + +function grantList(partial: Partial = {}): GrantList { + return { given: [], received: [], retentionDays: 365, ...partial }; +} + +vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useAccountGrants: () => ({ + data: grantList(), + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useRecordActivity: () => ({ + data: undefined, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useInviteGrant: () => idle(), + useAcceptGrant: () => idle(), + useRevokeGrant: () => idle(), + useRenounceGrant: () => idle(), + }; +}); + +/** + * The roster read, as each profile row sees it. + * + * Three states, not two. `"loading"` and `"error"` used to share `undefined`, + * which is precisely the conflation the error branch exists to end: a failed + * read that renders as a slow one is a person waiting for something that is + * never coming. + */ +const rosterRef: { + value: ManagedProfileGuardian[] | "loading" | "error"; +} = { value: [] }; +/** The deletion mutation, idle unless a case says otherwise. */ +const deleteRef = { value: idle() }; +/** The guardian invitation, idle unless a case says otherwise. */ +const inviteRef: { value: FakeMutation } = { value: idle() }; +/** The guardian removal, whose `variables` name a profile and a grant. */ +const removeGuardianRef: { + value: Omit & { + variables: { grantId: string } | undefined; + }; +} = { value: { ...idle(), variables: undefined } }; + +vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useCreateManagedProfile: () => idle(), + useDeleteManagedProfile: () => deleteRef.value, + useInviteManagedProfileGuardian: () => inviteRef.value, + useRemoveManagedProfileGuardian: () => removeGuardianRef.value, + useManagedProfileGuardians: () => ({ + data: Array.isArray(rosterRef.value) ? rosterRef.value : undefined, + isLoading: rosterRef.value === "loading", + isError: rosterRef.value === "error", + refetch: vi.fn(), + }), + }; +}); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +interface FakeMutation { + mutate: ReturnType; + isPending: boolean; + isError: boolean; + error: unknown; + variables: string | undefined; +} + +function idle(): FakeMutation { + return { + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + variables: undefined, + }; +} + +function failed(error: unknown, variables: string): FakeMutation { + return { ...idle(), isError: true, error, variables }; +} + +function guardian( + partial: Partial = {}, +): ManagedProfileGuardian { + return { + grantId: "g1", + account: { id: "acct-1", username: "guardian", displayName: "Alex" }, + state: "ACTIVE", + invitedAt: "2026-01-02T10:00:00.000Z", + acceptedAt: "2026-01-02T11:00:00.000Z", + ...partial, + }; +} + +import { AccessSection } from "@/components/settings/access-section"; +import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; +import { + createManagedProfileErrorKey, + displayNameIssue, + FieldGuidance, +} from "../managed-profile-create-form"; +import { + activeGuardianCount, + deleteProfileBody, + managedProfilesOf, +} from "../managed-profile-card"; +import { guardianInviteErrorKey } from "../managed-profile-guardians"; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +/** The section as the settings page actually mounts it: behind the gate. */ +function renderGatedSection(): string { + return render( + + + , + ); +} + +function entry(partial: Partial = {}): AccountAccessEntry { + return { + accountId: "p1", + username: "managed-abc", + displayName: "Managed record", + access: "write", + level: "manage", + recordKind: "managed", + sections: null, + canWrite: true, + ...partial, + }; +} + +beforeEach(() => { + authRef.value = { accounts: [], active: null }; + // The caller is always an active Guardian of the profile: it is the only way + // to have the roster at all. + rosterRef.value = [guardian({ account: SELF })]; + deleteRef.value = idle(); + inviteRef.value = idle(); + removeGuardianRef.value = { ...idle(), variables: undefined }; +}); + +/** The signed-in account, as the roster names it. */ +const SELF = { id: "guardian", username: "guardian", displayName: "You" }; + +describe("the managed-profile card, in the shared-access section", () => { + it("renders inside the section, after the invitation card", () => { + const html = renderGatedSection(); + expect(html).toContain('data-slot="managed-profile-card"'); + // Reading order: the invitation is the first act, this is the second, and + // the standing state of both follows. A card that drifted below the + // activity feed would still contain every string this file asserts. + expect(html.indexOf('data-slot="grant-invite-card"')).toBeLessThan( + html.indexOf('data-slot="managed-profile-card"'), + ); + expect(html.indexOf('data-slot="managed-profile-card"')).toBeLessThan( + html.indexOf('data-slot="grants-given-card"'), + ); + }); + + it("says the record has no login and that the creator looks after it", () => { + const html = renderGatedSection(); + // The two facts somebody is consenting to. A rewrite that keeps the form + // and drops either sentence should fail here. + expect(html).toContain("It has no login and no e-mail address"); + expect(html).toContain("you become its first guardian"); + }); + + it("collects the four fields the route accepts and nothing else", () => { + const html = renderGatedSection(); + for (const slot of [ + "managed-profile-name", + "managed-profile-dob", + "managed-profile-locale", + "managed-profile-create-submit", + ]) { + expect(html, slot).toContain(`data-slot="${slot}"`); + } + // The timezone control is the shared picker, which labels itself. + expect(html).toContain('id="managed-profile-timezone"'); + // Six languages offered, which is the route's enum exactly. + const options = + /]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/ + .exec(html)?.[0] + .match(/