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 =
+ /