diff --git a/messages/de.json b/messages/de.json index b928bc2ac..bd7fc6f34 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9642,6 +9642,29 @@ "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." + }, "toast": { "savedTo": "In der Akte von {name} gespeichert." }, diff --git a/messages/en.json b/messages/en.json index 208915163..782d47575 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9642,6 +9642,29 @@ "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." + }, "toast": { "savedTo": "Saved to {name}'s record." }, diff --git a/messages/es.json b/messages/es.json index 8cc2cb450..574c9cb63 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9642,6 +9642,29 @@ "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." + }, "toast": { "savedTo": "Guardado en el historial de {name}." }, diff --git a/messages/fr.json b/messages/fr.json index 6668aba8d..14efafce6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9642,6 +9642,29 @@ "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éé." + }, "toast": { "savedTo": "Enregistré dans le dossier de {name}." }, diff --git a/messages/it.json b/messages/it.json index e922a3689..e232ff48f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9642,6 +9642,29 @@ "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." + }, "toast": { "savedTo": "Salvato nella cartella di {name}." }, diff --git a/messages/pl.json b/messages/pl.json index 1866ce06b..2dc6cf27c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9642,6 +9642,29 @@ "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." + }, "toast": { "savedTo": "Zapisano w karcie użytkownika {name}." }, 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..2d616dc39 --- /dev/null +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -0,0 +1,351 @@ +/** + * 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"; + +/** 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(), + }; +}); + +vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, useCreateManagedProfile: () => idle() }; +}); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +function idle() { + return { + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + variables: undefined, + }; +} + +import { AccessSection } from "@/components/settings/access-section"; +import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; +import { + createManagedProfileErrorKey, + displayNameIssue, +} from "../managed-profile-create-form"; +import { managedProfilesOf } from "../managed-profile-card"; + +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 }; +}); + +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(/