feat: create managed profiles from shared access

- add a managed-profile card to Settings -> Shared access, between the
  invitation card and the standing-state cards
- collect exactly the four fields the create route accepts, with the date
  of birth genuinely optional and never derived from a year
- refresh the account payload on success, so the new record reaches the
  switcher and the banner without a reload
- assert the section is reachable for an owner and refused for a Guardian
  inside a profile and for an adult manager
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 01:53:26 +02:00
1 parent 466c9c906f
commit 110511ff31
14 files changed
+1188 -3

No files matched your search

+23
View File
@@ -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."
},
+23
View File
@@ -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."
},
+23
View File
@@ -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}."
},
+23
View File
@@ -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}."
},
+23
View File
@@ -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}."
},
+23
View File
@@ -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}."
},
+14 -3
View File
@@ -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 (
<div className="space-y-4">
<GrantInviteCard />
<ManagedProfileCard />
<GrantsGivenCard />
<GrantsReceivedCard />
<RecordActivityCard />
@@ -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<typeof import("@/hooks/use-auth")>();
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> = {}): GrantList {
return { given: [], received: [], retentionDays: 365, ...partial };
}
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/queries/use-account-grants")>();
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<typeof import("@/lib/queries/use-managed-profiles")>();
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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
/** The section as the settings page actually mounts it: behind the gate. */
function renderGatedSection(): string {
return render(
<RecordSettingsSectionGate section="access">
<AccessSection />
</RecordSettingsSectionGate>,
);
}
function entry(partial: Partial<AccountAccessEntry> = {}): 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 =
/<select[^>]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/
.exec(html)?.[0]
.match(/<option/g) ?? [];
expect(options).toHaveLength(6);
});
it("lists the profiles this account looks after, and only those", () => {
authRef.value = {
accounts: [
entry(),
entry({
accountId: "a1",
username: "housemate",
displayName: "Jo",
recordKind: "shared",
}),
],
active: null,
};
const html = renderGatedSection();
expect(html).toContain('data-managed-profile-id="p1"');
// An adult who shared their own record is not a profile anybody looks
// after, and offering a delete control for one would be the same mistake
// one level up.
expect(html).not.toContain('data-managed-profile-id="a1"');
expect(html.match(/data-slot="managed-profile-row"/g)).toHaveLength(1);
});
});
describe("who can reach the section at all", () => {
it("renders for an unswitched owner", () => {
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-card"');
expect(html).not.toContain("shared-record-settings-unavailable-title");
});
it("does not render for a Guardian inside the profile they look after", () => {
// Grant management is the one surface a delegate must have no reach into,
// and a Guardian acting AS the profile is a delegate for this purpose.
authRef.value = { accounts: [entry()], active: entry() };
const html = renderGatedSection();
expect(html).toContain("shared-record-settings-unavailable-title");
expect(html).not.toContain('data-slot="managed-profile-card"');
expect(html).not.toContain('data-slot="grant-invite-card"');
});
it("does not render for an adult delegate holding MANAGE", () => {
const adult = entry({
accountId: "a1",
username: "housemate",
displayName: "Jo",
recordKind: "shared",
});
authRef.value = { accounts: [adult], active: adult };
const html = renderGatedSection();
expect(html).toContain("shared-record-settings-unavailable-title");
expect(html).not.toContain('data-slot="managed-profile-card"');
});
});
describe("what the form decides before it sends", () => {
it("treats a whitespace-only name as empty, because the route does", () => {
// The route trims and then requires 1..80. A client that did not trim
// would offer to send a name the server reads as absent.
expect(displayNameIssue(" ")).toBe("recordSharing.managed.nameRequired");
expect(displayNameIssue("")).toBe("recordSharing.managed.nameRequired");
expect(displayNameIssue(" Managed record ")).toBeNull();
});
it("holds the route's own 80-character bound", () => {
expect(displayNameIssue("x".repeat(80))).toBeNull();
expect(displayNameIssue("x".repeat(81))).toBe(
"recordSharing.managed.nameTooLong",
);
// And trailing space does not push a valid name over the edge.
expect(displayNameIssue(`${"x".repeat(80)} `)).toBeNull();
});
it("routes the step-up gate to its own sentence, not to a failure", () => {
// Every route in this family resolves `requireFreshMfa`, so for anybody
// with a second factor this is the FIRST answer, not an error. Told as a
// failure it sends somebody looking for a problem with what they typed.
expect(
createManagedProfileErrorKey(
new ApiError("refused", 401, { errorCode: "auth.stepup.required" }),
),
).toBe("recordSharing.managed.errorStepUp");
expect(createManagedProfileErrorKey(new ApiError("refused", 422))).toBe(
"recordSharing.managed.errorInvalid",
);
expect(createManagedProfileErrorKey(new ApiError("refused", 500))).toBe(
"recordSharing.managed.errorFailed",
);
// Not an `ApiError` means no response came back at all.
expect(createManagedProfileErrorKey(new TypeError("Failed to fetch"))).toBe(
"recordSharing.managed.errorOffline",
);
// The four are distinct, so a resolver returning one key for everything
// fails here rather than passing each row above by accident.
expect(
new Set(
[401, 422, 500].map((s) =>
createManagedProfileErrorKey(new ApiError("refused", s)),
),
).size,
).toBe(3);
});
it("keeps a refused creation's fields, and clears them only on success", () => {
// A static render cannot press the button, so the claim is made about the
// code path: every reset lives inside `onSuccess`. A reset that migrated
// into `onError` would wipe a typed name on a failure the person is being
// asked to retry.
const source = readForm();
const start = source.indexOf("onSuccess:");
const split = source.indexOf("onError:", start);
expect(start).toBeGreaterThan(-1);
expect(split).toBeGreaterThan(start);
const onSuccess = source.slice(start, split);
const onError = source.slice(split);
expect(onSuccess).toContain("setCreated");
expect(onError).toContain("createManagedProfileErrorKey");
for (const reset of [
'setDisplayName("")',
'setDateOfBirth("")',
"setLocale(actorLocale)",
"setTimezone(browserTimezone())",
]) {
expect(onSuccess, reset).toContain(reset);
expect(onError, reset).not.toContain(reset);
}
});
});
describe("which accounts count as profiles looked after", () => {
it("keeps the managed entries and drops the rest", () => {
const managed = entry();
const shared = entry({ accountId: "a1", recordKind: "shared" });
expect(managedProfilesOf([managed, shared])).toEqual([managed]);
expect(managedProfilesOf(undefined)).toEqual([]);
});
});
function readForm(): string {
return readFileSync(
join(
process.cwd(),
"src/components/settings/access/managed-profile-create-form.tsx",
),
"utf8",
);
}
@@ -0,0 +1,107 @@
"use client";
import { UserRoundCog } from "lucide-react";
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
accountLabel,
type AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
/**
* v1.37.0 — the records this account looks after.
*
* ## Why the list is not a read of its own
*
* There is no `GET /api/managed-profiles`, and there does not need to be. A
* Guardian's relationship to a managed profile IS a MANAGE grant with the
* profile as grantor, so the profiles somebody looks after are already on
* `GET /api/auth/me` as the `managed` entries of `accountAccess.accounts` —
* the same list that paints the switcher. Reading them from anywhere else
* would be a second answer to a question the account payload already answers,
* and the two would disagree the first time a grant lapsed.
*
* It is also what makes the creation contract simple to state: the new profile
* appears here, in the switcher and in the banner from ONE invalidation, and
* if the account payload is not refreshed it appears in none of them.
*
* ## Why this card, in this section
*
* The section's docblock states the rule — one concept, one section — and this
* is the same concept from the third side: who can open a record. Two of the
* cards here are about another adult opening yours or you opening theirs; this
* one is about a record that exists only because somebody looks after it. A
* settings area of its own would split "who may open which record" across two
* pages, which is the split that rule exists to prevent.
*
* It sits directly after the invitation card because both are acts, and this
* one creates the record the other two then talk about.
*/
export function ManagedProfileCard() {
const { t } = useTranslations();
const { user } = useAuth();
const profiles = managedProfilesOf(user?.accountAccess?.accounts);
return (
<SettingsCard data-slot="managed-profile-card">
<SettingsCardHeader
icon={UserRoundCog}
title={t("recordSharing.managed.title")}
description={t("recordSharing.managed.description")}
/>
<div className="mt-4 space-y-4">
{/* Content, not meta: this is what somebody is consenting to take on,
and UI-STANDARDS §3 reserves muted for the incidental. */}
<p data-slot="managed-profile-explainer" className="text-sm">
{t("recordSharing.managed.explainer")}
</p>
{profiles.length > 0 && (
<ul data-slot="managed-profile-list" className="divide-y">
{profiles.map((profile) => (
<li
key={profile.accountId}
data-slot="managed-profile-row"
data-managed-profile-id={profile.accountId}
className="py-3 first:pt-0 last:pb-0"
>
{/* UI-STANDARDS §11 inline action row. */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* A person's name is user data and stays content. */}
<p className="truncate text-sm font-medium">
{accountLabel(profile)}
</p>
<p className="text-muted-foreground truncate text-xs">
{t("recordSharing.lookingAfter.kindManaged")}
</p>
</div>
</div>
</li>
))}
</ul>
)}
<ManagedProfileCreateForm />
</div>
</SettingsCard>
);
}
/**
* The managed entries of the account payload, in the order the server sent
* them.
*
* Exported and pure so the filter can be pinned: `recordKind` is server-
* resolved presentation metadata, and a card that showed every shared record
* here would offer a delete control for an adult's own account.
*/
export function managedProfilesOf(
accounts: AccountAccessEntry[] | undefined,
): AccountAccessEntry[] {
return (accounts ?? []).filter((entry) => entry.recordKind === "managed");
}
@@ -0,0 +1,287 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { ApiError } from "@/lib/api/api-fetch";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { useCreateManagedProfile } from "@/lib/queries/use-managed-profiles";
import {
DEFAULT_TIMEZONE,
detectBrowserTimezone,
isValidTimezone,
} from "@/lib/tz/format";
/**
* v1.37.0 — creating a record that has no login of its own.
*
* ## Why the form is exactly four fields
*
* `POST /api/managed-profiles` parses with a `.strict()` schema. A strict
* schema refuses an extra key outright, so a form that helpfully posted an
* empty `heightCm` — or a `gender` somebody thought would be useful — is
* refused with a validation error about a field the person never filled in.
* The four fields below are the schema, and the submit handler names them one
* by one rather than spreading the form state, so a fifth control cannot reach
* the wire by accident.
*
* ## The date of birth is genuinely optional
*
* Not "optional but we will guess". A managed profile is frequently a child,
* and a child's age changes what the product says about a reading — so the
* temptation to synthesise a date from a birth year, or from an age, is real
* and is refused here as it is in the route. An absent date of birth stays
* absent, and everything downstream reads it as absent rather than as
* "1 January of some year".
*
* ## Whose locale, and whose timezone
*
* The profile's locale is a property OF THE RECORD: it decides the language of
* content generated for it. The chrome around this form stays in the actor's
* language, which is a decision made elsewhere and is not re-litigated in the
* copy here. The default is the actor's own language, because a record created
* by a German speaker for a person in their household is overwhelmingly likely
* to want German, and the control is right there for the case where it is not.
*
* The timezone defaults to the browser's, for the same reason and with less
* ambiguity: the profile lives where the person creating it lives, until told
* otherwise.
*
* ## The step-up is the expected first answer
*
* Every route in this family resolves `requireFreshMfa`. For anybody with a
* second factor who has not proved it in the last few minutes, the FIRST
* response to this form is a 401 carrying `auth.stepup.required` — not an
* error, a gate. It gets its own sentence naming the next step, exactly as the
* invitation form does, rather than the generic "could not be created", which
* would send somebody looking for a problem with what they typed.
*/
export function ManagedProfileCreateForm() {
const { t, locale: actorLocale } = useTranslations();
const create = useCreateManagedProfile();
const [displayName, setDisplayName] = useState("");
const [dateOfBirth, setDateOfBirth] = useState("");
const [locale, setLocale] = useState<Locale>(actorLocale);
const [timezone, setTimezone] = useState(() => browserTimezone());
const [error, setError] = useState<string | null>(null);
const [created, setCreated] = useState<string | null>(null);
const trimmedName = displayName.trim();
const nameIssue = displayNameIssue(displayName);
const timezoneIssue = isValidTimezone(timezone)
? null
: "recordSharing.managed.timezoneInvalid";
const blocked = nameIssue !== null || timezoneIssue !== null;
const submit = (event: React.FormEvent) => {
event.preventDefault();
if (blocked || create.isPending) return;
setError(null);
setCreated(null);
create.mutate(
{
displayName: trimmedName,
// Empty means absent. `null` is sent rather than the key being
// omitted, so "this record has no date of birth" is something the
// client says out loud.
dateOfBirth: dateOfBirth.length > 0 ? dateOfBirth : null,
locale,
timezone,
},
{
onSuccess: (profile) => {
setDisplayName("");
setDateOfBirth("");
setLocale(actorLocale);
setTimezone(browserTimezone());
setCreated(profile.displayName);
},
// Nothing is cleared here, on purpose: a refused creation is one the
// person is being asked to retry, and a form that emptied itself
// would make them type it all again to find out whether the second
// attempt fails the same way.
onError: (err) => setError(t(createManagedProfileErrorKey(err))),
},
);
};
return (
<form
onSubmit={submit}
data-slot="managed-profile-create-form"
className="space-y-3"
>
<div className="space-y-1.5">
<Label htmlFor="managed-profile-name">
{t("recordSharing.managed.nameLabel")}
</Label>
<Input
id="managed-profile-name"
data-slot="managed-profile-name"
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
setError(null);
}}
placeholder={t("recordSharing.managed.namePlaceholder")}
autoComplete="off"
maxLength={80}
aria-invalid={
displayName.length > 0 && nameIssue !== null ? "true" : undefined
}
aria-describedby="managed-profile-name-hint"
/>
<p
id="managed-profile-name-hint"
data-slot="managed-profile-name-hint"
className="text-muted-foreground text-xs"
>
{displayName.length > 0 && nameIssue !== null
? t(nameIssue)
: t("recordSharing.managed.nameHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="managed-profile-dob">
{t("recordSharing.managed.dobLabel")}
</Label>
<DateField
id="managed-profile-dob"
data-slot="managed-profile-dob"
data-testid="managed-profile-dob-input"
value={dateOfBirth}
onChange={setDateOfBirth}
aria-describedby="managed-profile-dob-hint"
/>
<p
id="managed-profile-dob-hint"
className="text-muted-foreground text-xs"
>
{t("recordSharing.managed.dobHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="managed-profile-locale">
{t("recordSharing.managed.localeLabel")}
</Label>
<NativeSelect
id="managed-profile-locale"
data-slot="managed-profile-locale"
className="w-full"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
>
{locales.map((option) => (
<option key={option} value={option}>
{localeLabels[option]}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("recordSharing.managed.localeHint")}
</p>
</div>
<TimezonePicker
id="managed-profile-timezone"
value={timezone}
onChange={(next) => {
setTimezone(next);
setError(null);
}}
// The picker's own list comes from the engine, so the select branch
// cannot produce an unknown zone. Its free-text fallback (older
// engines without `Intl.supportedValuesOf`) can, and the submit button
// is inert for one — so the hint slot says why rather than leaving a
// dead button with no explanation beside it.
hint={t(timezoneIssue ?? "recordSharing.managed.timezoneHint")}
/>
<Button
type="submit"
data-slot="managed-profile-create-submit"
disabled={create.isPending || blocked}
>
{create.isPending
? t("recordSharing.managed.creating")
: t("recordSharing.managed.create")}
</Button>
{error && (
<p
role="alert"
data-slot="managed-profile-create-error"
className="text-destructive text-sm"
>
{error}
</p>
)}
{created && (
<p
role="status"
data-slot="managed-profile-created"
className="text-sm"
>
{t("recordSharing.managed.created", { name: created })}
</p>
)}
</form>
);
}
/** The browser's zone, or the app default when the engine will not say. */
function browserTimezone(): string {
const detected = detectBrowserTimezone();
return isValidTimezone(detected) ? detected : DEFAULT_TIMEZONE;
}
/**
* The route's own bound on the display name, checked before sending.
*
* Exported and pure so the bound can be pinned without a click. The route
* trims and then requires 1..80, so the client trims first too: a name of
* three spaces is empty to the server and must read as empty here, rather
* than as a valid entry that comes back refused.
*/
export function displayNameIssue(value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) return "recordSharing.managed.nameRequired";
if (trimmed.length > 80) return "recordSharing.managed.nameTooLong";
return null;
}
/**
* The message for a refused creation.
*
* Four arms and each names a different next step. The step-up arm is the one
* that matters: it is not a failure, it is the gate every route in this family
* carries, and it is the answer most people will get the first time they use
* this form in a session.
*
* A 422 gets its own sentence rather than the field-level list the server
* actually sends. The multi-issue envelope rides `details.issues`, and the
* client's fetch wrapper keeps only `meta` — so the issues do not reach the
* browser at all. The form validates the same bounds the route does before it
* sends anything, which makes a 422 a disagreement between the two rather than
* something the person typed; saying so is more honest than inventing a
* field-level message the server did not send. Recorded as a finding in
* `02-13.1-CONTRACT-FINDINGS.md`.
*/
export function createManagedProfileErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
// The step-up gate. `requireFreshMfa` throws `StepUpRequiredError`, which is
// a 401 carrying a stable code, and the request was authenticated enough to
// reach it — so this is never a lapsed session.
if (err.status === 401) return "recordSharing.managed.errorStepUp";
if (err.status === 422) return "recordSharing.managed.errorInvalid";
return "recordSharing.managed.errorFailed";
}
@@ -0,0 +1,156 @@
/**
* A created managed profile reaches the switcher.
*
* ## The claim, and why it is not "the mutation calls invalidate"
*
* The list of profiles somebody looks after is not a read of its own. It is
* `accountAccess.accounts` on `GET /api/auth/me`, filtered to the managed
* entries — the same block that paints the account switcher and the shared-
* record banner. So "the new profile appears without a reload" is exactly one
* property: the account payload comes back invalidated when the creation
* succeeds. A mutation that refreshed the sharing panel and not the payload
* would leave the profile invisible in the card, in the switcher and in the
* banner at once, and nothing on screen would say a read was stale.
*
* That claim can only be answered by a real `QueryClient` with the reads in
* it, so the hook's success handler is driven against one. `@tanstack/react-
* query` is mocked down to the two functions the hook body calls, which is
* what lets the body run outside React: the unit environment is `node`, there
* is no jsdom and no testing-library in this repo.
*
* Mutation check, run: point `invalidateManagedProfileReads` at the grant list
* alone and "refreshes the account payload" goes red while every other leg
* here stays green.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
import { queryKeys } from "@/lib/query-keys";
import { __resetRecordScopeForTests } from "@/lib/query-keys/record-scope";
/** The client the mocked `useQueryClient` hands back. Reassigned per case. */
const clientRef: { value: QueryClient } = { value: seeded() };
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
// The hook body is what is under test, so the two hooks it calls are
// replaced by the smallest things that let it run: one that records the
// options object, one that hands over a real client.
useMutation: (options: unknown) => options,
useQueryClient: () => clientRef.value,
};
});
import {
invalidateManagedProfileReads,
useCreateManagedProfile,
} from "@/lib/queries/use-managed-profiles";
function seeded(): QueryClient {
const client = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});
client.setQueryData(queryKeys.accountGrants(), {
given: [],
received: [],
retentionDays: 365,
});
client.setQueryData(queryKeys.accountActivity(), {
entries: [],
retentionDays: 365,
truncated: false,
});
client.setQueryData(queryKeys.authMe(), { id: "guardian" });
client.setQueryData(queryKeys.managedProfileGuardians("p1"), []);
return client;
}
function invalidated(client: QueryClient, key: readonly unknown[]): boolean {
return (
client.getQueryCache().find({ queryKey: key })?.state.isInvalidated === true
);
}
beforeEach(() => {
__resetRecordScopeForTests();
clientRef.value = seeded();
});
describe("what a managed-profile change refreshes", () => {
it("starts from four live, valid entries", () => {
// Non-zero proof. Without it a client that failed to seed and a client
// whose entries were all invalidated read identically below: every
// `find()` returns undefined and each assertion becomes a quiet
// `undefined !== true`.
const all = clientRef.value.getQueryCache().getAll();
expect(all).toHaveLength(4);
expect(all.every((q) => q.state.isInvalidated)).toBe(false);
});
it("refreshes the account payload, which carries the switcher", () => {
invalidateManagedProfileReads(clientRef.value);
expect(invalidated(clientRef.value, queryKeys.authMe())).toBe(true);
});
it("refreshes the grant list and the record-activity feed", () => {
invalidateManagedProfileReads(clientRef.value);
expect(invalidated(clientRef.value, queryKeys.accountGrants())).toBe(true);
expect(invalidated(clientRef.value, queryKeys.accountActivity())).toBe(
true,
);
});
it("refreshes a profile's guardian roster through the family prefix", () => {
// Accepting a Guardian invitation is an ordinary grant accept, and it
// moves a row on a roster that endpoint knows nothing about. The prefix is
// what keeps the two in step.
invalidateManagedProfileReads(clientRef.value);
expect(
invalidated(clientRef.value, queryKeys.managedProfileGuardians("p1")),
).toBe(true);
});
});
describe("the creation hook, driven for real", () => {
it("posts the four fields the strict schema accepts, and no others", async () => {
const options = useCreateManagedProfile() as unknown as {
mutationFn: (input: unknown) => Promise<unknown>;
onSuccess: () => void;
};
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ data: { id: "p9" }, error: null }), {
status: 201,
headers: { "content-type": "application/json" },
}),
);
await options.mutationFn({
displayName: "Managed record",
dateOfBirth: null,
locale: "en",
timezone: "Europe/Berlin",
});
const [path, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(path).toBe("/api/managed-profiles");
expect(init.method).toBe("POST");
expect(Object.keys(JSON.parse(String(init.body))).sort()).toEqual([
"dateOfBirth",
"displayName",
"locale",
"timezone",
]);
fetchSpy.mockRestore();
});
it("puts the new profile in the switcher by refreshing the payload", () => {
const options = useCreateManagedProfile() as unknown as {
onSuccess: () => void;
};
options.onSuccess();
expect(invalidated(clientRef.value, queryKeys.authMe())).toBe(true);
});
});
+98
View File
@@ -0,0 +1,98 @@
"use client";
/**
* v1.37.0 — the reads and writes behind the managed-profile surface.
*
* A managed profile is a record with no login of its own. It is created by the
* person who will look after it, and from that moment it exists only through
* its Guardians — which is why every call here refreshes the ACCOUNT payload
* and not merely the panel it was fired from.
*
* That is the one property worth stating up front. The list of profiles a
* Guardian looks after is not a read of its own: it is `accountAccess.accounts`
* on `GET /api/auth/me`, filtered to the managed entries, because the Guardian's
* relationship to the profile IS a MANAGE grant. So a creation that refreshed
* the sharing panel and not the payload would leave the new profile invisible —
* in the switcher, in the banner, and in this card — until the next boot. Every
* mutation below therefore goes through `invalidateGrantReads`, the one entry
* `use-account-grants.ts` already publishes for exactly this fan-out; the
* managed-profile family rides it as a key prefix rather than as a second
* invalidation path (`grantDependentKeys`).
*/
import {
useMutation,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
import type { Locale } from "@/lib/i18n/config";
/**
* Exactly the body `POST /api/managed-profiles` accepts.
*
* The route's schema is `.strict()`, so an extra key is a 422 with nothing
* useful in it — a form that helpfully posted an empty `heightCm` would be
* refused for a field the person never saw. Naming the four fields as a type
* rather than passing the form state through is what stops that: a fifth field
* added to the form cannot reach the wire without somebody deciding it should.
*/
export interface CreateManagedProfileInput {
displayName: string;
/** ISO `yyyy-MM-dd`, or null. Never synthesised from a year. */
dateOfBirth: string | null;
locale: Locale;
/** IANA zone name. */
timezone: string;
}
/** What the create route answers with. */
export interface ManagedProfileCreated {
id: string;
displayName: string;
dateOfBirth: string | null;
locale: string;
timezone: string;
recordKind: "managed";
}
/**
* Everything a managed-profile lifecycle change makes stale.
*
* Exported as a plain function over a `QueryClient` for the same reason
* `invalidateGrantReads` is: a test can hand it a real client, seed the reads
* and check they came back invalidated. "The mutation calls invalidate" is a
* claim about the source; "the account payload comes back invalidated" is the
* claim that decides whether the new profile appears in the switcher.
*/
export function invalidateManagedProfileReads(queryClient: QueryClient): void {
invalidateGrantReads(queryClient);
}
/** The same refresh, bound to the mounted client. */
function useInvalidateManagedProfiles() {
const queryClient = useQueryClient();
return () => invalidateManagedProfileReads(queryClient);
}
/**
* Create a record somebody looks after.
*
* `requireFreshMfa` guards the route, so a 401 carrying
* `meta.errorCode: auth.stepup.required` is the EXPECTED first answer for
* anybody who has a second factor and has not proved it recently. The form
* routes that into the app's re-verification path rather than into a generic
* failure — see `managed-profile-create-form.tsx`.
*/
export function useCreateManagedProfile() {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileCreate(),
mutationFn: (input: CreateManagedProfileInput) =>
apiPost<ManagedProfileCreated>("/api/managed-profiles", input),
onSuccess: invalidate,
});
}
+9
View File
@@ -81,11 +81,20 @@ export { recordSettingsKeys } from "./record-settings";
* and the banner. A revoke that refreshed the panel but not the payload would
* leave a switcher entry pointing at a record the server had just closed: the
* affordance still on screen, the click already refused.
*
* v1.37.0 — the managed-profile family joins the bundle as a prefix. A
* Guardian invitation is accepted through the ordinary
* `POST /api/account/grants/{id}/accept`, and that acceptance moves a row on a
* roster the accept route knows nothing about: the invitee stops being PENDING
* and starts counting toward the last-Guardian floor. Listing the prefix here
* keeps the roster in step with the act that changed it, rather than asking
* every future grant transition to remember one more read.
*/
export const grantDependentKeys = [
queryKeys.accountGrants(),
queryKeys.accountActivity(),
queryKeys.authMe(),
queryKeys.managedProfiles(),
];
/**
+28
View File
@@ -28,10 +28,38 @@ export const sharingKeys = {
*/
accountActivity: () => ["account", "activity"] as const,
/**
* v1.37.0 — the managed-profile family, as one prefix.
*
* Present so a grant transition can invalidate the whole family at once:
* accepting a Guardian invitation is an ordinary `POST
* /api/account/grants/{id}/accept`, and it moves a row on a roster that
* endpoint knows nothing about. Listing this prefix in `grantDependentKeys`
* is what keeps the two in step without the accept path having to know a
* managed profile exists.
*/
managedProfiles: () => ["managed-profiles"] as const,
/**
* Who looks after one managed profile (`GET
* /api/managed-profiles/{id}/guardians`). Keyed by profile because a
* Guardian may look after more than one, and a single key for all of them
* would be the same-key-different-shape collision the factory exists to
* prevent.
*/
managedProfileGuardians: (profileId: string) =>
["managed-profiles", profileId, "guardians"] as const,
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
accountGrantInvite: () => ["account", "grants", "invite"] as const,
accountGrantAccept: () => ["account", "grants", "accept"] as const,
accountGrantRevoke: () => ["account", "grants", "revoke"] as const,
accountGrantRenounce: () => ["account", "grants", "renounce"] as const,
accountSwitchMutation: () => ["account", "switch"] as const,
managedProfileCreate: () => ["managed-profiles", "create"] as const,
managedProfileDelete: () => ["managed-profiles", "delete"] as const,
managedProfileGuardianInvite: () =>
["managed-profiles", "guardian", "invite"] as const,
managedProfileGuardianRemove: () =>
["managed-profiles", "guardian", "remove"] as const,
};