mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
466c9c906f
commit
110511ff31
14 files changed
+1188
-3
No files matched your search
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
Reference in new issue
Block a user