feat: let a guardian edit a managed record, and record its sex

A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).

PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.

The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.

Refs #939
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 00:01:50 +02:00
1 parent 1d372df3a0
commit f521b03c35
20 files changed
+1535 -58

No files matched your search

+12
View File
@@ -10269,6 +10269,12 @@
"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.",
"genderLabel": "Geschlecht (optional)",
"genderHint": "Entscheidet, ob die Zyklus-Funktion für diese Akte zunächst an ist. Jederzeit änderbar.",
"genderNone": "Keine Angabe",
"genderMale": "Männlich",
"genderFemale": "Weiblich",
"genderOther": "Divers",
"timezoneHint": "Die Zeitzone, in der die Tage dieser Akte gezählt werden.",
"timezoneInvalid": "Diese Zeitzone kennt der Browser nicht.",
"create": "Profil anlegen",
@@ -10278,6 +10284,12 @@
"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.",
"edit": "Bearbeiten",
"editSave": "Änderungen speichern",
"editSaving": "Wird gespeichert…",
"editLoadError": "Diese Angaben konnten nicht geladen werden.",
"editErrorGone": "Diese Akte gibt es nicht mehr, oder du betreust sie nicht mehr.",
"editErrorFailed": "Die Änderungen konnten nicht gespeichert werden.",
"delete": "Profil löschen",
"deleteTitle": "{name} löschen?",
"deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.",
+12
View File
@@ -10269,6 +10269,12 @@
"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.",
"genderLabel": "Gender (optional)",
"genderHint": "Decides whether cycle tracking starts on for this record. Change it any time.",
"genderNone": "Not specified",
"genderMale": "Male",
"genderFemale": "Female",
"genderOther": "Other",
"timezoneHint": "The zone this record's days are measured in.",
"timezoneInvalid": "This browser does not know that timezone.",
"create": "Create profile",
@@ -10278,6 +10284,12 @@
"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.",
"edit": "Edit",
"editSave": "Save changes",
"editSaving": "Saving…",
"editLoadError": "Those details could not be loaded.",
"editErrorGone": "That record is gone, or you no longer look after it.",
"editErrorFailed": "The changes could not be saved.",
"delete": "Delete profile",
"deleteTitle": "Delete {name}?",
"deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.",
+12
View File
@@ -10269,6 +10269,12 @@
"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.",
"genderLabel": "Sexo (opcional)",
"genderHint": "Determina si el seguimiento del ciclo empieza activado en este registro. Se puede cambiar cuando quieras.",
"genderNone": "Sin especificar",
"genderMale": "Hombre",
"genderFemale": "Mujer",
"genderOther": "Otro",
"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",
@@ -10278,6 +10284,12 @@
"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.",
"edit": "Editar",
"editSave": "Guardar cambios",
"editSaving": "Guardando…",
"editLoadError": "No se han podido cargar esos datos.",
"editErrorGone": "Ese registro ya no existe, o ya no te ocupas de él.",
"editErrorFailed": "No se han podido guardar los cambios.",
"delete": "Eliminar perfil",
"deleteTitle": "¿Eliminar {name}?",
"deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.",
+12
View File
@@ -10269,6 +10269,12 @@
"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.",
"genderLabel": "Sexe (facultatif)",
"genderHint": "Détermine si le suivi du cycle est activé au départ pour ce dossier. Modifiable à tout moment.",
"genderNone": "Non précisé",
"genderMale": "Homme",
"genderFemale": "Femme",
"genderOther": "Autre",
"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",
@@ -10278,6 +10284,12 @@
"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éé.",
"edit": "Modifier",
"editSave": "Enregistrer les modifications",
"editSaving": "Enregistrement…",
"editLoadError": "Ces informations n'ont pas pu être chargées.",
"editErrorGone": "Ce dossier n'existe plus, ou vous ne vous en occupez plus.",
"editErrorFailed": "Les modifications n'ont pas pu être enregistrées.",
"delete": "Supprimer le profil",
"deleteTitle": "Supprimer {name} ?",
"deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.",
+12
View File
@@ -10269,6 +10269,12 @@
"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.",
"genderLabel": "Sesso (facoltativo)",
"genderHint": "Determina se il monitoraggio del ciclo parte attivo per questa scheda. Modificabile in qualsiasi momento.",
"genderNone": "Non specificato",
"genderMale": "Uomo",
"genderFemale": "Donna",
"genderOther": "Altro",
"timezoneHint": "Il fuso in cui si contano le giornate di questa cartella.",
"timezoneInvalid": "Questo browser non conosce quel fuso orario.",
"create": "Crea profilo",
@@ -10278,6 +10284,12 @@
"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.",
"edit": "Modifica",
"editSave": "Salva le modifiche",
"editSaving": "Salvataggio…",
"editLoadError": "Non è stato possibile caricare questi dati.",
"editErrorGone": "Questa scheda non esiste più, oppure non te ne occupi più.",
"editErrorFailed": "Non è stato possibile salvare le modifiche.",
"delete": "Elimina profilo",
"deleteTitle": "Eliminare {name}?",
"deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.",
+12
View File
@@ -10269,6 +10269,12 @@
"dobHint": "적고 싶지 않으면 비워 두세요. 연도로 뭔가를 추측하지는 않아요.",
"localeLabel": "이 기록의 언어",
"localeHint": "이 기록에 적히는 글의 언어예요. 내 화면은 내 언어 그대로예요.",
"genderLabel": "성별(선택)",
"genderHint": "이 기록에서 주기 추적을 처음에 켜둘지 결정합니다. 언제든 바꿀 수 있습니다.",
"genderNone": "지정 안 함",
"genderMale": "남성",
"genderFemale": "여성",
"genderOther": "기타",
"timezoneHint": "이 기록의 하루를 재는 기준 시간대예요.",
"timezoneInvalid": "이 브라우저가 모르는 시간대예요.",
"create": "프로필 만들기",
@@ -10278,6 +10284,12 @@
"errorOffline": "서버에 닿지 못했어요. 바뀐 것은 없으니 연결을 확인하고 다시 시도해 주세요.",
"errorInvalid": "서버가 그 내용을 거절했어요. 이름과 날짜, 시간대를 확인해 주세요.",
"errorFailed": "프로필을 만들지 못했어요.",
"edit": "편집",
"editSave": "변경 사항 저장",
"editSaving": "저장 중…",
"editLoadError": "해당 정보를 불러오지 못했습니다.",
"editErrorGone": "그 기록이 없거나 더 이상 관리하지 않습니다.",
"editErrorFailed": "변경 사항을 저장하지 못했습니다.",
"delete": "프로필 삭제",
"deleteTitle": "{name} 님 프로필을 삭제할까요?",
"deleteBody": "{name} 님에 대해 기록한 모든 것이 프로필과 함께 사라져요. 측정값, 약, 문서, 메모 전부요. 되돌릴 수 없어요.",
+12
View File
@@ -10269,6 +10269,12 @@
"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.",
"genderLabel": "Płeć (opcjonalnie)",
"genderHint": "Decyduje, czy śledzenie cyklu jest na start włączone dla tego profilu. Możesz to zmienić w każdej chwili.",
"genderNone": "Nie podano",
"genderMale": "Mężczyzna",
"genderFemale": "Kobieta",
"genderOther": "Inna",
"timezoneHint": "Strefa, w której liczone są dni tej karty.",
"timezoneInvalid": "Ta przeglądarka nie zna takiej strefy czasowej.",
"create": "Utwórz profil",
@@ -10278,6 +10284,12 @@
"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.",
"edit": "Edytuj",
"editSave": "Zapisz zmiany",
"editSaving": "Zapisywanie…",
"editLoadError": "Nie udało się wczytać tych danych.",
"editErrorGone": "Tego profilu już nie ma albo już się nim nie opiekujesz.",
"editErrorFailed": "Nie udało się zapisać zmian.",
"delete": "Usuń profil",
"deleteTitle": "Usunąć {name}?",
"deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.",
+121 -6
View File
@@ -3,17 +3,136 @@ import { NextRequest } from "next/server";
import {
apiHandler,
MFA_STEP_UP_MAX_AGE_SECONDS,
requireCookieAuth,
requireFreshMfa,
} from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import {
deleteManagedProfile,
ManagedProfileLifecycleError,
readManagedProfileForGuardian,
updateManagedProfile,
} from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { updateManagedProfileSchema } from "@/lib/validations/managed-profiles";
type RouteParams = { params: Promise<{ id: string }> };
/** The one refusal this file answers with for every "you may not have it". */
const notFound = () =>
apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
/**
* The record as it stands.
*
* The read the edit form fills itself from, and the reason it exists at all:
* `accountAccess.accounts` on the account payload carries a managed record's
* NAME and nothing else, so a form offered from the Guardian's own panel had
* no way to show the timezone, the language or the date of birth it was about
* to overwrite. A form that opens with blank fields and PUTs them is how a
* record loses its timezone to an edit of its name.
*
* Cookie-only and NOT step-up gated, exactly like the guardian roster beside
* it and for the same reason: this is a read, it discloses to a Guardian what
* they already administer, and the gate belongs to the acts. (Named
* `requireCookieAuth` rather than described by the session helper it calls —
* see the roster route's docblock for why that distinction is load-bearing.)
*
* An ACTOR surface: the profile is named in the path and the caller acts as
* themselves, so it answers from the Guardian's own account rather than only
* while switched into the record.
*/
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireCookieAuth();
const { id } = await params;
const profile = await readManagedProfileForGuardian({
profileId: id,
guardianId: user.id,
});
// One refusal for "no such account", "not a managed profile" and "not a
// Guardian of it", so the route is not an enumeration oracle.
if (!profile) return notFound();
annotate({
action: { name: "managed_profile.read" },
meta: { profile_id: id },
});
return apiSuccess(profile);
},
);
/**
* Change a managed record's identity.
*
* ## Why the same gate as deletion
*
* `requireFreshMfa`, unconditionally, which is what creation and deletion
* already carry. The three acts are one family: they mint, change and end an
* account that can never prove anything about itself, so the proof has to come
* from the person doing it. Softening the gate here — "it is only a rename" —
* would leave the display name of every record somebody looks after writable
* from an ambient cookie, and the display name is what every other Guardian,
* the switcher and the record banner identify the record by.
*
* A Guardian who is already switched INTO the record edits the same five
* columns through `PATCH /api/record-settings/profile` without a step-up, and
* that is not a contradiction: reaching that surface at all requires the
* switch, which is itself an act on the record. This route is the one reachable
* from the Guardian's own panel, where nothing else has been proved.
*
* ## Which fields, and why not more
*
* The five the creation takes. Everything else a `User` row carries —
* credentials, provider connections, notification routing, the health profile
* — is deliberately out of reach: this is the record's identity, not its
* account. The wider configuration surface is `PATCH
* /api/record-settings/{family}`, which is guardian-fenced and switched-in.
*/
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateManagedProfileSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// The parsed patch goes to the service, which assembles the Prisma payload
// field by field. One assembly rather than one here and one there: two
// statements of which fields are writable is one statement too many.
let profile;
try {
profile = await updateManagedProfile({
profileId: id,
guardianId: user.id,
patch: parsed.data,
});
} catch (error) {
if (error instanceof ManagedProfileLifecycleError) return notFound();
throw error;
}
annotate({
action: { name: "managed_profile.update" },
meta: { profile_id: id, changed: Object.keys(parsed.data) },
});
return apiSuccess(profile);
},
);
/** Delete a managed record only after its cookie-backed Guardian proves MFA. */
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
@@ -23,11 +142,7 @@ export const DELETE = apiHandler(
try {
await deleteManagedProfile({ profileId: id, guardianId: user.id });
} catch (error) {
if (error instanceof ManagedProfileLifecycleError) {
return apiError("Managed profile not found", 404, {
errorCode: "managed_profile.not_found",
});
}
if (error instanceof ManagedProfileLifecycleError) return notFound();
throw error;
}
+6 -11
View File
@@ -13,6 +13,7 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
@@ -62,6 +63,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
: null,
locale: parsed.data.locale,
timezone: parsed.data.timezone,
// Absent and null both mean "not recorded". The record keeps the same
// NULL an account that never answered carries, and the cycle module
// derives its default from it rather than from a guess about the person.
gender: parsed.data.gender ?? null,
});
await auditLog("managed_profile.created", {
@@ -73,15 +78,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: { profile_id: profile.id },
});
return apiSuccess(
{
id: profile.id,
displayName: profile.displayName,
dateOfBirth: profile.dateOfBirth,
locale: profile.locale,
timezone: profile.timezone,
recordKind: "managed" as const,
},
201,
);
return apiSuccess(toManagedProfileView(profile), 201);
});
@@ -262,12 +262,16 @@ describe("the managed-profile card, in the shared-access section", () => {
expect(html).toContain("you become its first guardian");
});
it("collects the four fields the route accepts and nothing else", () => {
it("collects the five fields the route accepts and nothing else", () => {
const html = renderGatedSection();
for (const slot of [
"managed-profile-name",
"managed-profile-dob",
"managed-profile-locale",
// v1.38.14 (#939) — the fifth. The route's schema is `.strict()`, so a
// control the schema does not name is a 422 about a field nobody filled
// in; this list and the schema are the same list.
"managed-profile-gender",
"managed-profile-create-submit",
]) {
expect(html, slot).toContain(`data-slot="${slot}"`);
@@ -280,6 +284,27 @@ describe("the managed-profile card, in the shared-access section", () => {
.exec(html)?.[0]
.match(/<option/g) ?? [];
expect(options).toHaveLength(7);
// Four sex options, and the first is the empty one: null is a real answer
// ("not recorded") rather than an absent field, and a form offering only
// the three enum members would make it unsayable.
const genderOptions =
/<select[^>]*data-slot="managed-profile-gender"[\s\S]*?<\/select>/
.exec(html)?.[0]
.match(/<option/g) ?? [];
expect(genderOptions).toHaveLength(4);
});
it("offers an edit control on every row it lists", () => {
// Issue #939: a managed record could be created and deleted and never
// changed, so a name typed as a placeholder was permanent. The control is
// offered while the guardian roster is still in flight, unlike the
// deletion beside it, whose confirm copy states how many people lose the
// record and therefore cannot exist before that read answers.
authRef.value = { accounts: [entry()], active: null };
rosterRef.value = "loading";
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-edit"');
expect(html).not.toContain('data-slot="managed-profile-delete"');
});
it("lists the profiles this account looks after, and only those", () => {
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { UserRoundCog } from "lucide-react";
import {
@@ -7,9 +8,11 @@ import {
grantActionErrorKey,
} from "@/components/settings/access/grant-action-error";
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
import { ManagedProfileEditForm } from "@/components/settings/access/managed-profile-edit-form";
import { ManagedProfileGuardians } from "@/components/settings/access/managed-profile-guardians";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAuth } from "@/hooks/use-auth";
@@ -127,6 +130,10 @@ function ManagedProfileRow({
const guardians = useManagedProfileGuardians(profile.accountId);
const name = accountLabel(profile);
const failed = remove.isError && remove.variables === profile.accountId;
// Per row, not per card. The edit form reads the record's identity when it
// opens, so one shared flag would put every row's form on the wire the
// moment any of them was opened.
const [editing, setEditing] = useState(false);
return (
<li
@@ -143,8 +150,24 @@ function ManagedProfileRow({
{t("recordSharing.lookingAfter.kindManaged")}
</p>
</div>
{guardians.data && (
<div className="flex shrink-0 items-center">
{/* UI-STANDARDS §11: the actions stay beside the name, never below it.
Edit is offered without waiting for the roster — it changes what the
record IS and does not depend on how many people look after it,
unlike the deletion beside it, whose confirm copy states that
number and therefore cannot exist before the roster answers. */}
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="managed-profile-edit"
aria-expanded={editing}
onClick={() => setEditing((open) => !open)}
>
{t("recordSharing.managed.edit")}
</Button>
{guardians.data && (
<ConfirmButton
slot="managed-profile-delete"
size="sm"
@@ -161,9 +184,15 @@ function ManagedProfileRow({
pending={remove.isPending}
onConfirm={() => remove.mutate(profile.accountId)}
/>
</div>
)}
)}
</div>
</div>
{editing && (
<ManagedProfileEditForm
profileId={profile.accountId}
onDone={() => setEditing(false)}
/>
)}
{failed && (
<GrantActionAlert
grantId={profile.accountId}
@@ -12,7 +12,10 @@ import { stepUpErrorKey } from "@/components/settings/access/grant-action-error"
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 {
useCreateManagedProfile,
type ManagedProfileGender,
} from "@/lib/queries/use-managed-profiles";
import {
DEFAULT_TIMEZONE,
detectBrowserTimezone,
@@ -22,15 +25,22 @@ import {
/**
* v1.37.0 — creating a record that has no login of its own.
*
* ## Why the form is exactly four fields
* ## Why the form is exactly five 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.
* empty `heightCm` is refused with a validation error about a field the person
* never filled in. The five fields below are the schema, and the submit
* handler names them one by one rather than spreading the form state, so a
* sixth control cannot reach the wire by accident.
*
* The fifth is `gender`, and it arrived late for a stated reason (#939). It
* was left out of v1.37.0 on the argument that a record's identity should ask
* for as little as possible — and the consequence was that every managed
* record started with `gender` NULL, which is what the cycle module derives
* its default from. A guardian creating a record for a child could neither
* record the answer nor turn the module off from here. Asking once, optionally,
* is less intrusive than the module surface that had to exist to undo it.
*
* ## The date of birth is genuinely optional
*
@@ -70,6 +80,7 @@ export function ManagedProfileCreateForm() {
const [displayName, setDisplayName] = useState("");
const [dateOfBirth, setDateOfBirth] = useState("");
const [locale, setLocale] = useState<Locale>(actorLocale);
const [gender, setGender] = useState<ManagedProfileGender>(null);
const [timezone, setTimezone] = useState(() => browserTimezone());
const [error, setError] = useState<string | null>(null);
const [created, setCreated] = useState<string | null>(null);
@@ -95,14 +106,16 @@ export function ManagedProfileCreateForm() {
dateOfBirth: dateOfBirth.length > 0 ? dateOfBirth : null,
locale,
timezone,
gender,
},
{
onSuccess: (profile) => {
setDisplayName("");
setDateOfBirth("");
setLocale(actorLocale);
setGender(null);
setTimezone(browserTimezone());
setCreated(profile.displayName);
setCreated(profile.displayName ?? trimmedName);
},
// Nothing is cleared here, on purpose: a refused creation is one the
// person is being asked to retry, and a form that emptied itself
@@ -193,6 +206,28 @@ export function ManagedProfileCreateForm() {
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="managed-profile-gender">
{t("recordSharing.managed.genderLabel")}
</Label>
<NativeSelect
id="managed-profile-gender"
data-slot="managed-profile-gender"
className="w-full"
value={gender ?? ""}
onChange={(e) => setGender(genderFromSelect(e.target.value))}
>
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey)}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("recordSharing.managed.genderHint")}
</p>
</div>
<div className="space-y-1.5">
<TimezonePicker
id="managed-profile-timezone"
@@ -307,6 +342,36 @@ export function FieldGuidance({
);
}
/**
* The sex control's options, in the order the record's own settings offer them.
*
* Shared with the edit form rather than written twice: the empty value is the
* NULL the column really stores, and a second copy of this list is how one
* surface ends up offering four options and the other three.
*/
export const MANAGED_PROFILE_GENDER_OPTIONS: ReadonlyArray<{
value: string;
labelKey: string;
}> = [
{ value: "", labelKey: "recordSharing.managed.genderNone" },
{ value: "MALE", labelKey: "recordSharing.managed.genderMale" },
{ value: "FEMALE", labelKey: "recordSharing.managed.genderFemale" },
{ value: "OTHER", labelKey: "recordSharing.managed.genderOther" },
];
/**
* The select's string, as the route's enum.
*
* Exported and pure because the empty option is the one that matters: it means
* NULL — "not recorded" — and a form that sent `""` would be refused by the
* enum for a choice the person deliberately made.
*/
export function genderFromSelect(value: string): ManagedProfileGender {
return value === "MALE" || value === "FEMALE" || value === "OTHER"
? value
: null;
}
/** The browser's zone, or the app default when the engine will not say. */
function browserTimezone(): string {
const detected = detectBrowserTimezone();
@@ -0,0 +1,361 @@
"use client";
import { useState, type FormEvent } from "react";
import { Loader2 } from "lucide-react";
import {
FieldGuidance,
MANAGED_PROFILE_GENDER_OPTIONS,
displayNameIssue,
genderFromSelect,
} from "@/components/settings/access/managed-profile-create-form";
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { TimezonePicker } from "@/components/settings/timezone-picker";
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 { QueryErrorCard } from "@/components/ui/query-error-card";
import { ApiError } from "@/lib/api/api-fetch";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import {
useManagedProfile,
useUpdateManagedProfile,
type ManagedProfileGender,
type ManagedProfileView,
type UpdateManagedProfileInput,
} from "@/lib/queries/use-managed-profiles";
import { isValidTimezone } from "@/lib/tz/format";
/**
* v1.38.14 (#939) — changing a record after it exists.
*
* ## Why the form loads before it renders
*
* The account payload names a managed record and nothing else, so this form
* cannot be filled from what the card already holds. It reads
* `GET /api/managed-profiles/{id}` first and paints nothing editable until
* that answers. The alternative — open the fields blank and send whatever is
* in them — is how a record loses its timezone to an edit of its name, and it
* would be invisible: the write would succeed.
*
* The read is `enabled` only while the form is open, so a Guardian who looks
* after four records does not fetch four identities to paint a list that shows
* none of them.
*
* ## Why it sends only what moved
*
* `PATCH` reads an absent key as "leave it" and an explicit null on the two
* nullable fields as "clear it". The diff below is what keeps those two
* different: a form that always sent five fields would overwrite a timezone
* somebody changed in the record's own settings while this form sat open, with
* the value it read minutes earlier. It also makes the audit row honest —
* `changed` is derived from the body, so "the guardian renamed the record" is
* what the record's trail says when that is what happened.
*
* ## The step-up is the expected first answer
*
* Same family, same gate as creating and deleting: `requireFreshMfa`, so the
* first save in a session is a 401 carrying `auth.stepup.required`. It gets
* its own sentence naming the next step rather than the generic failure, which
* would send somebody looking for a problem with what they typed.
*/
export function ManagedProfileEditForm({
profileId,
onDone,
}: {
profileId: string;
/** Called after a save lands, so the row can close the form. */
onDone: () => void;
}) {
const { t } = useTranslations();
const profile = useManagedProfile(profileId);
if (profile.isError) {
return (
<QueryErrorCard
title={t("recordSharing.managed.editLoadError")}
onRetry={() => void profile.refetch()}
/>
);
}
if (!profile.data) {
return (
<p
role="status"
data-slot="managed-profile-edit-loading"
className="text-muted-foreground flex items-center gap-2 text-sm"
>
<Loader2
className="h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("nav.loadingScreen")}
</p>
);
}
// Keyed on the loaded record so the field state below is seeded from it once
// rather than through an effect that re-seeds on every refetch — a refetch
// landing mid-edit must not silently replace what somebody is typing.
return (
<ManagedProfileEditFields
key={profile.dataUpdatedAt}
profile={profile.data}
onDone={onDone}
/>
);
}
function ManagedProfileEditFields({
profile,
onDone,
}: {
profile: ManagedProfileView;
onDone: () => void;
}) {
const { t, locale: actorLocale } = useTranslations();
const update = useUpdateManagedProfile();
const [displayName, setDisplayName] = useState(profile.displayName ?? "");
const [dateOfBirth, setDateOfBirth] = useState(profile.dateOfBirth ?? "");
// A record with no recorded language opens on the actor's, which is the same
// default the creation form uses. It is a starting point for a control the
// person is looking at, not an inference written behind their back — nothing
// is sent until they save.
const [locale, setLocale] = useState<Locale>(
isLocale(profile.locale) ? profile.locale : actorLocale,
);
const [gender, setGender] = useState<ManagedProfileGender>(profile.gender);
const [timezone, setTimezone] = useState(profile.timezone);
const [error, setError] = useState<string | null>(null);
const nameIssue = displayNameIssue(displayName);
const timezoneIssue = isValidTimezone(timezone)
? null
: "recordSharing.managed.timezoneInvalid";
const patch = managedProfileEditPatch(profile, {
displayName,
dateOfBirth,
locale,
gender,
timezone,
});
const blocked =
nameIssue !== null || timezoneIssue !== null || patch === null;
const submit = (event: FormEvent) => {
event.preventDefault();
// `blocked` already covers it; narrowed again so the spread below is typed
// rather than asserted.
if (blocked || patch === null || update.isPending) return;
setError(null);
update.mutate(
{ profileId: profile.id, ...patch },
{
onSuccess: onDone,
// Nothing is reset: a refused save is one somebody is being asked to
// retry, and a form that restored the server's values would throw the
// edit away to explain that it did not land.
onError: (err) => setError(t(editManagedProfileErrorKey(err))),
},
);
};
return (
<form
onSubmit={submit}
data-slot="managed-profile-edit-form"
className="space-y-3"
>
<div className="space-y-1.5">
<Label htmlFor={`managed-profile-edit-name-${profile.id}`}>
{t("recordSharing.managed.nameLabel")}
</Label>
<Input
id={`managed-profile-edit-name-${profile.id}`}
data-slot="managed-profile-edit-name"
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
setError(null);
}}
autoComplete="off"
maxLength={80}
aria-invalid={nameIssue !== null ? "true" : undefined}
/>
<FieldGuidance
slot="managed-profile-edit-name"
error={nameIssue === null ? null : t(nameIssue)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`managed-profile-edit-dob-${profile.id}`}>
{t("recordSharing.managed.dobLabel")}
</Label>
<DateField
id={`managed-profile-edit-dob-${profile.id}`}
data-slot="managed-profile-edit-dob"
value={dateOfBirth}
onChange={setDateOfBirth}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`managed-profile-edit-locale-${profile.id}`}>
{t("recordSharing.managed.localeLabel")}
</Label>
<NativeSelect
id={`managed-profile-edit-locale-${profile.id}`}
data-slot="managed-profile-edit-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>
</div>
<div className="space-y-1.5">
<Label htmlFor={`managed-profile-edit-gender-${profile.id}`}>
{t("recordSharing.managed.genderLabel")}
</Label>
<NativeSelect
id={`managed-profile-edit-gender-${profile.id}`}
data-slot="managed-profile-edit-gender"
className="w-full"
value={gender ?? ""}
onChange={(e) => setGender(genderFromSelect(e.target.value))}
>
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey)}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("recordSharing.managed.genderHint")}
</p>
</div>
<div className="space-y-1.5">
<TimezonePicker
id={`managed-profile-edit-timezone-${profile.id}`}
value={timezone}
onChange={(next) => {
setTimezone(next);
setError(null);
}}
hint={t("recordSharing.managed.timezoneHint")}
/>
<FieldGuidance
slot="managed-profile-edit-timezone"
error={timezoneIssue === null ? null : t(timezoneIssue)}
/>
</div>
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
{error && (
<p
role="alert"
data-slot="managed-profile-edit-error"
className="text-destructive text-sm"
>
{error}
</p>
)}
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="managed-profile-edit-cancel"
onClick={onDone}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="managed-profile-edit-submit"
disabled={blocked || update.isPending}
>
{update.isPending
? t("recordSharing.managed.editSaving")
: t("recordSharing.managed.editSave")}
</Button>
</SettingsCardActions>
</form>
);
}
/** Is this the record's language, as the app knows languages. */
function isLocale(value: string | null): value is Locale {
return value !== null && (locales as readonly string[]).includes(value);
}
/** The form's five controls, as strings, before they become a patch. */
interface ManagedProfileEditDraft {
displayName: string;
dateOfBirth: string;
locale: Locale;
gender: ManagedProfileGender;
timezone: string;
}
/**
* What actually changed, or `null` when nothing did.
*
* Exported and pure because it is the decision this form makes: the route
* refuses an empty body, so "nothing changed" has to be answered here rather
* than discovered as a 422, and a field the person did not touch must not ride
* along and overwrite a concurrent edit made in the record's own settings.
* The empty date field is `null` — "clear it" — and not the empty string,
* which the route's date format would refuse.
*/
export function managedProfileEditPatch(
profile: ManagedProfileView,
draft: ManagedProfileEditDraft,
): Omit<UpdateManagedProfileInput, "profileId"> | null {
const displayName = draft.displayName.trim();
const dateOfBirth = draft.dateOfBirth.length > 0 ? draft.dateOfBirth : null;
const patch: Omit<UpdateManagedProfileInput, "profileId"> = {
...(displayName !== (profile.displayName ?? "") ? { displayName } : {}),
...(dateOfBirth !== profile.dateOfBirth ? { dateOfBirth } : {}),
...(draft.locale !== profile.locale ? { locale: draft.locale } : {}),
...(draft.gender !== profile.gender ? { gender: draft.gender } : {}),
...(draft.timezone !== profile.timezone
? { timezone: draft.timezone }
: {}),
};
return Object.keys(patch).length > 0 ? patch : null;
}
/**
* The message for a refused edit.
*
* The same four arms creation carries, minus the rate limit it does not have,
* and with the same reasoning: the step-up arm is a gate rather than a failure
* and must not read as one, and a 422 is a disagreement between this form's
* bounds and the route's rather than something the person typed — the
* multi-issue envelope's `details.issues` never reach the browser.
*/
export function editManagedProfileErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
if (err.status === 401) return stepUpErrorKey(err);
if (err.status === 422) return "recordSharing.managed.errorInvalid";
if (err.status === 404) return "recordSharing.managed.editErrorGone";
return "recordSharing.managed.editErrorFailed";
}
+7
View File
@@ -12,6 +12,12 @@ export interface CreateManagedProfileInput {
dateOfBirth: Date | null;
locale: string;
timezone: string;
/**
* `User.gender` — "MALE" | "FEMALE" | "OTHER". Omitted or null both mean not
* recorded, and the column keeps the NULL an account that never answered
* carries; nothing is inferred from the name or from the date of birth.
*/
gender?: string | null;
}
export interface CreatedManagedProfile {
@@ -58,6 +64,7 @@ export async function createManagedProfile(
passwordHash: null,
displayName: input.displayName,
dateOfBirth: input.dateOfBirth,
gender: input.gender ?? null,
locale: input.locale,
timezone: input.timezone,
managedProfileAt: now,
+185
View File
@@ -214,6 +214,191 @@ export async function deleteManagedProfile(input: {
});
}
/**
* One managed record, as its own family publishes it.
*
* The same five fields the creation answers with, plus `gender`. It is a view
* and not the row: a `User` carries credentials, provider connections and the
* whole health profile, and a Guardian panel needs the record's identity, not
* its account.
*/
export interface ManagedProfileView {
id: string;
displayName: string | null;
/** ISO `yyyy-MM-dd`, or null. Never synthesised from a year. */
dateOfBirth: string | null;
gender: string | null;
/**
* The column is nullable and this view says so. Every record this family
* creates carries one — creation requires it — so null here means a row that
* predates the requirement or was written by something else, and a client
* renders its own language for it rather than the string "null".
*/
locale: string | null;
timezone: string;
recordKind: "managed";
}
/** The columns the view is built from, kept in one place for both readers. */
const MANAGED_PROFILE_VIEW_SELECT = {
id: true,
displayName: true,
dateOfBirth: true,
gender: true,
locale: true,
timezone: true,
} as const;
export function toManagedProfileView(row: {
id: string;
displayName: string | null;
dateOfBirth: Date | null;
gender: string | null;
locale: string | null;
timezone: string;
}): ManagedProfileView {
return {
id: row.id,
displayName: row.displayName,
dateOfBirth: row.dateOfBirth?.toISOString().slice(0, 10) ?? null,
gender: row.gender,
locale: row.locale,
timezone: row.timezone,
recordKind: "managed",
};
}
/**
* The record as it stands, or `null` when the caller may not have it.
*
* One `null` for "no such account", "not a managed profile" and "you are not
* an active Guardian of it", exactly as the roster read answers, so the route
* above it is not an enumeration oracle.
*
* No lock and no transaction, for the same reason the roster takes neither:
* this guards no invariant. It is the read an edit form fills itself from, and
* a form filled from a snapshot that moved under it is refused by the write,
* not by the read.
*/
export async function readManagedProfileForGuardian(input: {
profileId: string;
guardianId: string;
now?: Date;
}): Promise<ManagedProfileView | null> {
const profile = await prisma.user.findUnique({
where: { id: input.profileId },
select: { ...MANAGED_PROFILE_VIEW_SELECT, managedProfileAt: true },
});
if (!profile?.managedProfileAt) return null;
const guardian = await prisma.accountGrant.findFirst({
where: {
grantorId: profile.id,
granteeId: input.guardianId,
...activeGuardianWhere(input.now ?? new Date()),
},
select: { id: true },
});
if (!guardian) return null;
return toManagedProfileView(profile);
}
/**
* The fields a Guardian may change, in the shape the route's schema parses
* them into — `dateOfBirth` an ISO `yyyy-MM-dd` string rather than a `Date`.
*
* The parsed body arrives here and the column assembly happens below, in one
* place. Doing it at the route and again here would be two statements of which
* fields are writable, and the second one to be forgotten is the one that
* matters.
*/
export interface ManagedProfileEdit {
displayName?: string;
dateOfBirth?: string | null;
locale?: string;
timezone?: string;
gender?: string | null;
}
/**
* Change a managed record's identity.
*
* Guarded exactly as the deletion beside it is: the same advisory lock, the
* same managed-profile marker, and the same active-Guardian grant lookup, so
* the two acts on one record cannot disagree about who may perform them. The
* lock is not strictly needed to write five columns — it is taken because a
* concurrent deletion holds it, and an edit that landed between that
* transaction's guardian check and its `user.delete` would be a write to a row
* on its way out.
*
* The Prisma payload is assembled field by field from the parsed patch — never
* by spreading it — and the `changed` list the audit row carries is derived
* from the same object, so the trail names what actually moved rather than
* what the form rendered.
*/
export async function updateManagedProfile(input: {
profileId: string;
guardianId: string;
patch: ManagedProfileEdit;
}): Promise<ManagedProfileView> {
return prisma.$transaction(async (tx) => {
return withManagedProfileLock(tx, input.profileId, async (profile) => {
if (!profile) throw new ManagedProfileLifecycleError("not_found");
if (!profile.managedProfileAt) {
throw new ManagedProfileLifecycleError("not_managed");
}
const guardian = await tx.accountGrant.findFirst({
where: {
grantorId: profile.id,
granteeId: input.guardianId,
...activeGuardianWhere(new Date()),
},
select: { id: true },
});
if (!guardian) throw new ManagedProfileLifecycleError("not_guardian");
const patch = input.patch;
const data = {
...(patch.displayName !== undefined
? { displayName: patch.displayName }
: {}),
...(patch.dateOfBirth !== undefined
? {
dateOfBirth: patch.dateOfBirth
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
: null,
}
: {}),
...(patch.locale !== undefined ? { locale: patch.locale } : {}),
...(patch.timezone !== undefined ? { timezone: patch.timezone } : {}),
...(patch.gender !== undefined ? { gender: patch.gender } : {}),
};
const updated = await tx.user.update({
where: { id: profile.id },
data,
select: MANAGED_PROFILE_VIEW_SELECT,
});
// Filed under the RECORD with the Guardian named as the actor, which is
// how every other record-scoped change is filed. The record's activity
// trail is the one an incoming Guardian reads to learn what happened to
// it, and a row filed under whoever happened to be at the keyboard would
// leave that trail with a hole in it.
await auditLog("managed_profile.updated", {
userId: profile.id,
actorUserId: input.guardianId,
details: { changed: Object.keys(data) },
client: tx,
});
return toManagedProfileView(updated);
});
});
}
/**
* Internal-only future compatibility hook. There is intentionally no route for
* this state change in the current product; holding the same lock ensures any
+103 -9
View File
@@ -32,6 +32,7 @@ import {
import {
createManagedProfileSchema,
inviteManagedProfileGuardianSchema,
updateManagedProfileSchema,
} from "@/lib/validations/managed-profiles";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -63,7 +64,13 @@ const accountGrantInvite = inviteGrantSchema.meta({
const createManagedProfileRequest = createManagedProfileSchema.meta({
id: "CreateManagedProfileRequest",
description:
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
});
const updateManagedProfileRequest = updateManagedProfileSchema.meta({
id: "UpdateManagedProfileRequest",
description:
"Change what a managed record is, from the Guardian's own panel. The same five fields creation takes, every one of them optional, and at least one required — an empty body is a 422 rather than a no-op that audits a change nobody made. Absence means leave the field alone; an explicit null on `dateOfBirth` or `gender` clears it, and the two are different answers. Nothing else on the record is reachable here: credentials, provider connections, notification routing and the health profile are not identity, and the wider configuration surface is `PATCH /api/record-settings/{family}`, which is guardian-fenced and answers only while switched into the record. Strict, cookie-only and step-up gated, like every act in this family.",
});
const inviteManagedProfileGuardianRequest =
@@ -183,9 +190,25 @@ const managedProfileCreated = z
.describe(
"The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector take, and the `{id}` in this family's paths.",
),
displayName: z.string(),
dateOfBirth: z.string().nullable(),
locale: z.string(),
displayName: z.string().nullable(),
dateOfBirth: z
.string()
.nullable()
.describe(
"`YYYY-MM-DD`, or null. The same date-only shape the request takes — this is a calendar date, not an instant, and publishing it with a time would invite a client to apply a zone to it.",
),
gender: z
.enum(["MALE", "FEMALE", "OTHER"])
.nullable()
.describe(
"`User.gender` for this record. Null means not recorded, which is a real answer and not a missing one.",
),
locale: z
.string()
.nullable()
.describe(
"The record's own language. Every profile this family creates carries one, because creation requires it; the column is nullable, so null means a row written before that and a client renders its own language rather than the word.",
),
timezone: z.string(),
recordKind: z
.literal("managed")
@@ -196,7 +219,7 @@ const managedProfileCreated = z
.meta({
id: "ManagedProfile",
description:
"A record somebody looks after, as its creation answers with it. There is deliberately no list endpoint: the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner until the next boot.",
"A record somebody looks after, as its creation, its read and its edit all answer with it. There is deliberately no list endpoint: the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner until the next boot.",
});
const endedGuardianGrant = z
@@ -433,6 +456,19 @@ const accountPayload = z
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this payload still describes the CALLER — their preferences, their modules, their identity — because display preferences belong to the person at the keyboard rather than to the record they are reading.",
});
/**
* One envelope, three routes.
*
* `dataEnvelope` mints a new schema on every call, and two schemas cannot share
* a component id — so creating, reading and editing a managed profile have to
* reference the SAME const rather than call the helper three times. They do
* describe the same thing, which is why they may.
*/
const managedProfileEnvelope = dataEnvelope(
managedProfileCreated,
"ManagedProfileEnvelope",
);
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/me": {
get: {
@@ -676,10 +712,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The new record.",
content: {
"application/json": {
schema: dataEnvelope(
managedProfileCreated,
"ManagedProfileEnvelope",
),
schema: managedProfileEnvelope,
},
},
},
@@ -697,6 +730,67 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
"/api/managed-profiles/{id}": {
get: {
tags: ["Account sharing"],
summary: "Read a managed record's identity",
description:
"What the record is right now — the five fields its creation took. The read an edit form fills itself from, and the reason it exists: `accountAccess.accounts` on the account payload carries the record's name and nothing else, so a form offered from the Guardian's own panel had no way to show the timezone, the language or the date of birth it was about to overwrite. Cookie transport only, an ACTOR surface (the profile is named in the path and the caller acts as themselves, so it answers without switching into the record), and NOT step-up gated: this is a read, it discloses to a Guardian only what they already administer, and the gate belongs to the acts.",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
...stdResponses,
"200": {
description: "The record as it stands.",
content: {
"application/json": {
schema: managedProfileEnvelope,
},
},
},
"404": {
description:
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
patch: {
tags: ["Account sharing"],
summary: "Change a managed record's identity",
description:
"Edit the record after it exists — the name that was a placeholder, the timezone that moved with the household, the date of birth somebody finally has, the sex that decides whether the cycle module is on. Any active Guardian may do it, from a cookie session with a fresh second factor: the same gate creation and deletion carry, because the three acts mint, change and end an account that can never prove anything about itself. Answers with the whole record, so a client re-renders from the server's version rather than from what it sent.",
requestParams: {
path: z.object({ id: z.string() }),
},
requestBody: {
required: true,
content: {
"application/json": { schema: updateManagedProfileRequest },
},
},
responses: {
...stdResponses,
"200": {
description: "The record as it now stands.",
content: {
"application/json": {
schema: managedProfileEnvelope,
},
},
},
"401": {
description:
"No fresh second-factor proof, or none enrolled. Same unconditional gate as creation and deletion.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description:
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
delete: {
tags: ["Account sharing"],
summary: "Delete a managed record",
+109 -6
View File
@@ -27,7 +27,7 @@ import {
type QueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
import type { GrantParty } from "@/lib/queries/use-account-grants";
@@ -49,18 +49,44 @@ export interface CreateManagedProfileInput {
locale: Locale;
/** IANA zone name. */
timezone: string;
/** `User.gender`. Null is a real answer — "not recorded" — not an omission. */
gender: ManagedProfileGender;
}
/** What the create route answers with. */
export interface ManagedProfileCreated {
/** The three values `User.gender` takes, plus the honest absence. */
export type ManagedProfileGender = "MALE" | "FEMALE" | "OTHER" | null;
/** What the create, read and update routes all answer with. */
export interface ManagedProfileView {
id: string;
displayName: string;
displayName: string | null;
dateOfBirth: string | null;
locale: string;
gender: ManagedProfileGender;
/** Nullable, as the column is. A record created here always carries one. */
locale: string | null;
timezone: string;
recordKind: "managed";
}
/**
* The body `PATCH /api/managed-profiles/{id}` accepts, plus the record it is
* addressed to.
*
* Every field optional, at least one required, and `.strict()` on the server —
* so the mutation names the fields one by one rather than spreading form state,
* exactly as the creation does. An absent key means "leave it"; an explicit
* null on the two nullable fields clears it. `profileId` addresses the request
* and is deliberately not in the body: the schema would refuse it.
*/
export interface UpdateManagedProfileInput {
profileId: string;
displayName?: string;
dateOfBirth?: string | null;
locale?: Locale;
timezone?: string;
gender?: ManagedProfileGender;
}
/**
* Everything a managed-profile lifecycle change makes stale.
*
@@ -94,11 +120,88 @@ export function useCreateManagedProfile() {
return useMutation({
mutationKey: queryKeys.managedProfileCreate(),
mutationFn: (input: CreateManagedProfileInput) =>
apiPost<ManagedProfileCreated>("/api/managed-profiles", input),
apiPost<ManagedProfileView>("/api/managed-profiles", input),
onSuccess: invalidate,
});
}
/**
* The record as it stands, for the form that is about to change it.
*
* The account payload names a managed record and nothing more, so a form
* opened from the Guardian's own panel cannot show the timezone, the language,
* the date of birth or the sex it is about to overwrite. This is that read.
* `enabled` is what keeps it off the wire until somebody opens the form: a
* Guardian with four records should not fetch four identities to paint a list
* that shows none of them.
*/
export function useManagedProfile(profileId: string, enabled = true) {
return useQuery({
queryKey: queryKeys.managedProfile(profileId),
queryFn: () =>
apiGet<ManagedProfileView>(`/api/managed-profiles/${profileId}`),
enabled,
});
}
/**
* Change a record somebody looks after.
*
* Guarded by `requireFreshMfa` like every other act in this family, so a 401
* carrying `meta.errorCode: auth.stepup.required` is the expected first answer
* rather than a failure — the form routes it into the re-verification path.
*
* The whole family is invalidated on success because the display name is what
* the switcher, the record banner and this card all identify the record by, and
* the record's own identity read is dropped alongside so a second edit opens
* from the server's version rather than from the one that was just replaced.
*/
export function useUpdateManagedProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.managedProfileUpdate(),
mutationFn: (input: UpdateManagedProfileInput) =>
apiPatch<ManagedProfileView>(
`/api/managed-profiles/${input.profileId}`,
managedProfileEditBody(input),
),
onSuccess: (_result, input) => {
invalidateManagedProfileReads(queryClient);
void queryClient.invalidateQueries({
queryKey: queryKeys.managedProfile(input.profileId),
});
},
});
}
/**
* The wire body, composed once and exported.
*
* Pure and separate from the mutation for the same reason `guardianInviteBody`
* is: the integration suite posts the REAL body through the REAL route rather
* than a hand-written copy, which is the assembly a two-sided test misses.
*
* Each key is present only when the caller named it, because the route reads
* absence as "leave it" and an explicit `undefined` would serialise to nothing
* anyway — stating it is what stops a later spread from sending five keys for a
* one-field edit.
*/
export function managedProfileEditBody(
input: UpdateManagedProfileInput,
): Record<string, unknown> {
return {
...(input.displayName !== undefined
? { displayName: input.displayName }
: {}),
...(input.dateOfBirth !== undefined
? { dateOfBirth: input.dateOfBirth }
: {}),
...(input.locale !== undefined ? { locale: input.locale } : {}),
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
...(input.gender !== undefined ? { gender: input.gender } : {}),
};
}
/** One person who looks after a profile, as the roster publishes them. */
export interface ManagedProfileGuardian {
/** The grant row — what `DELETE .../guardians/{grantId}` takes. */
+9
View File
@@ -50,6 +50,14 @@ export const sharingKeys = {
managedProfileGuardians: (profileId: string) =>
["managed-profiles", profileId, "guardians"] as const,
/**
* One managed record's identity (`GET /api/managed-profiles/{id}`). Keyed by
* profile for the same reason the roster is, and under the same prefix so a
* grant transition invalidates it with the rest of the family.
*/
managedProfile: (profileId: string) =>
["managed-profiles", profileId, "profile"] 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,
@@ -57,6 +65,7 @@ export const sharingKeys = {
accountGrantRenounce: () => ["account", "grants", "renounce"] as const,
accountSwitchMutation: () => ["account", "switch"] as const,
managedProfileCreate: () => ["managed-profiles", "create"] as const,
managedProfileUpdate: () => ["managed-profiles", "update"] as const,
managedProfileDelete: () => ["managed-profiles", "delete"] as const,
managedProfileGuardianInvite: () =>
["managed-profiles", "guardian", "invite"] as const,
+65 -13
View File
@@ -1,33 +1,85 @@
/**
* v1.37.0 — the two bodies the managed-profile family accepts.
* v1.37.0 — the bodies the managed-profile family accepts.
*
* Here rather than beside each route because the OpenAPI table publishes them,
* and a schema written twice is a contract that drifts on the third change.
* The route modules import these; nothing imports the route modules.
*
* Both are `.strict()`. A managed profile is a person's health record created
* by somebody else, so a field the sender did not mean to send is refused
* rather than ignored — and the client types name exactly these fields for the
* same reason (`src/lib/queries/use-managed-profiles.ts`).
* Every one is `.strict()`. A managed profile is a person's health record
* created by somebody else, so a field the sender did not mean to send is
* refused rather than ignored — and the client types name exactly these fields
* for the same reason (`src/lib/queries/use-managed-profiles.ts`).
*/
import { z } from "zod/v4";
import { isValidTimezone } from "@/lib/tz/format";
/**
* The record's sex, as `User.gender` already stores it.
*
* The column is the one every other surface writes — `PATCH /api/auth/me`, the
* onboarding baseline, and the guardian's own `PATCH
* /api/record-settings/profile` — so this family reuses its name and its three
* values rather than minting a second word for one column. `null` is a real
* answer ("not recorded"), not an absent field: a record created for a child
* whose guardian would rather not record it says so, and the cycle module
* derives OFF from it exactly as it does for an account that never answered.
*/
const managedProfileGenderSchema = z.enum(["MALE", "FEMALE", "OTHER"]);
/** The fields both bodies below share, with their bounds stated once. */
const managedProfileFields = {
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone"),
gender: managedProfileGenderSchema.nullable(),
} as const;
/** The body `POST /api/managed-profiles` accepts. */
export const createManagedProfileSchema = z
.object({
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone"),
displayName: managedProfileFields.displayName,
dateOfBirth: managedProfileFields.dateOfBirth.optional(),
locale: managedProfileFields.locale,
timezone: managedProfileFields.timezone,
gender: managedProfileFields.gender.optional(),
})
.strict();
/**
* The body `PATCH /api/managed-profiles/{id}` accepts.
*
* Every field optional and at least one required. A managed record is edited
* long after it was created — a name that was a placeholder, a timezone that
* moved with the household, a date of birth somebody finally has — and a
* PUT-shaped body would make changing the name mean re-sending the other four,
* with a stale copy of each. So absence means "leave it", and `null` on the two
* nullable fields means "clear it": the two are different answers and the
* schema keeps them different.
*
* The refinement is not decoration. An empty strict object parses, and without
* it an empty body would write nothing, audit a change, and answer 200 —
* a no-op that looks like an edit in the record's own activity trail.
*/
export const updateManagedProfileSchema = z
.object({
displayName: managedProfileFields.displayName.optional(),
dateOfBirth: managedProfileFields.dateOfBirth.optional(),
locale: managedProfileFields.locale.optional(),
timezone: managedProfileFields.timezone.optional(),
gender: managedProfileFields.gender.optional(),
})
.strict()
.refine(
(patch) => Object.values(patch).some((value) => value !== undefined),
{ message: "Name at least one field to change" },
);
/** The body `POST /api/managed-profiles/{id}/guardians` accepts. */
export const inviteManagedProfileGuardianSchema = z
.object({
@@ -0,0 +1,353 @@
/**
* Reading and changing a managed record's identity, through the real routes
* and a real Postgres (#939).
*
* The card's controls are proved from a static render in
* `src/components/settings/access/__tests__/managed-profile-affordances.test.tsx`,
* which can say a control exists and nothing about what pressing it does. This
* file is the other half, and it posts the body the CLIENT composes
* (`managedProfileEditBody`) rather than a hand-written copy of it: a
* hand-written copy stays green while the form renames a field, and the
* assembly between the two ends is the part that drops.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
import { managedProfileEditBody } from "@/lib/queries/use-managed-profiles";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar, queuedSessionIds } =
await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => {
const snapshot = new Map(cookieJar);
const queuedSessionId = queuedSessionIds.shift();
if (queuedSessionId) {
snapshot.set("healthlog_session", queuedSessionId);
}
return {
get: (name: string) => {
const value = snapshot.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
};
}),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
let sequence = 0;
interface Person {
id: string;
username: string;
sessionId: string;
}
/** An account with a second factor and a session that has just proved it. */
async function person(label: string): Promise<Person> {
const suffix = sequence++;
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: `${label}-${suffix}`,
email: `${label}-${suffix}@example.test`,
totpConfirmedAt: new Date(),
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
mfaVerifiedAt: new Date(),
},
});
return { id: user.id, username: user.username, sessionId: session.id };
}
function signIn(who: Person): void {
headerJar.delete("authorization");
cookieJar.set("healthlog_session", who.sessionId);
}
async function readProfile(profileId: string) {
const { GET } = await import("@/app/api/managed-profiles/[id]/route");
return GET(
new NextRequest(`http://localhost/api/managed-profiles/${profileId}`, {
method: "GET",
}),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function patchProfile(profileId: string, body: unknown) {
const { PATCH } = await import("@/app/api/managed-profiles/[id]/route");
return PATCH(
new NextRequest(`http://localhost/api/managed-profiles/${profileId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function guardianAndProfile() {
const guardian = await person("guardian");
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Placeholder",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
return { guardian, profile };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("GET /api/managed-profiles/{id}", () => {
it("answers a Guardian with the record's own identity", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await readProfile(profile.id);
expect(response.status).toBe(200);
const { data } = await response.json();
// Asserted with `Object.keys` rather than by presence: a route that
// over-discloses passes every "contains" assertion ever written about it,
// and this one answers about an account with no self to consent.
expect(Object.keys(data).sort()).toEqual([
"dateOfBirth",
"displayName",
"gender",
"id",
"locale",
"recordKind",
"timezone",
]);
expect(data).toMatchObject({
id: profile.id,
displayName: "Placeholder",
dateOfBirth: null,
gender: null,
locale: "en",
timezone: "UTC",
recordKind: "managed",
});
});
it("answers somebody who is not a Guardian the unknown-record refusal", async () => {
const { profile } = await guardianAndProfile();
const stranger = await person("stranger");
signIn(stranger);
const response = await readProfile(profile.id);
// 404 rather than 403, and deliberately: "no such record" and "not yours"
// are byte-identical here so the route is not an enumeration oracle.
expect(response.status).toBe(404);
expect((await response.json()).meta?.errorCode).toBe(
"managed_profile.not_found",
);
});
});
describe("PATCH /api/managed-profiles/{id}", () => {
it("changes the name and the timezone a Guardian sends, and audits both", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await patchProfile(
profile.id,
managedProfileEditBody({
profileId: profile.id,
displayName: "Robin",
timezone: "Europe/Berlin",
}),
);
expect(response.status).toBe(200);
expect((await response.json()).data).toMatchObject({
displayName: "Robin",
timezone: "Europe/Berlin",
// Untouched by a two-field patch. Absence means "leave it", and a body
// that always sent five fields would overwrite these with whatever the
// form last read.
locale: "en",
dateOfBirth: null,
gender: null,
});
const prisma = getPrismaClient();
const row = await prisma.user.findUniqueOrThrow({
where: { id: profile.id },
select: { displayName: true, timezone: true, locale: true },
});
expect(row).toEqual({
displayName: "Robin",
timezone: "Europe/Berlin",
locale: "en",
});
// Filed under the RECORD with the Guardian named as the actor: the
// record's own trail is what an incoming Guardian reads to learn what
// happened to it, and it names only what actually moved.
const audit = await prisma.auditLog.findFirstOrThrow({
where: { action: "managed_profile.updated", userId: profile.id },
});
expect(audit.actorUserId).toBe(guardian.id);
expect(
((audit.details as { changed?: string[] } | null)?.changed ?? []).sort(),
).toEqual(["displayName", "timezone"]);
});
it("records the sex a Guardian sets, which is what the cycle module reads", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await patchProfile(
profile.id,
managedProfileEditBody({ profileId: profile.id, gender: "MALE" }),
);
expect(response.status).toBe(200);
expect((await response.json()).data.gender).toBe("MALE");
// Not a second column and not a mirror: `User.gender` is the one the cycle
// gate derives from, so the record it was recorded on is the record whose
// module map answers.
const { isModuleEnabled } = await import("@/lib/modules/gate");
await expect(isModuleEnabled(profile.id, "cycle")).resolves.toBe(false);
});
it("clears a date of birth on an explicit null and leaves it on an absent key", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
await patchProfile(profile.id, { dateOfBirth: "2015-04-02" });
const kept = await patchProfile(profile.id, { displayName: "Robin" });
expect((await kept.json()).data.dateOfBirth).toBe("2015-04-02");
const cleared = await patchProfile(profile.id, { dateOfBirth: null });
expect((await cleared.json()).data.dateOfBirth).toBeNull();
});
it("refuses a body that names nothing, rather than auditing a change nobody made", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
const response = await patchProfile(profile.id, {});
expect(response.status).toBe(422);
const rows = await getPrismaClient().auditLog.count({
where: { action: "managed_profile.updated" },
});
expect(rows).toBe(0);
});
it("refuses a field the schema does not name", async () => {
const { guardian, profile } = await guardianAndProfile();
signIn(guardian);
// `.strict()`, so a helpful extra key is a refusal rather than an ignored
// one. `heightCm` is a real `User` column and the closest thing to a
// plausible mistake.
const response = await patchProfile(profile.id, {
displayName: "Robin",
heightCm: 120,
});
expect(response.status).toBe(422);
});
it("refuses somebody who is not a Guardian, and changes nothing", async () => {
const { profile } = await guardianAndProfile();
const stranger = await person("stranger");
signIn(stranger);
const response = await patchProfile(profile.id, { displayName: "Mine" });
expect(response.status).toBe(404);
expect((await response.json()).meta?.errorCode).toBe(
"managed_profile.not_found",
);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: profile.id },
select: { displayName: true },
});
expect(row.displayName).toBe("Placeholder");
});
it("refuses an adult MANAGE delegate on an ordinary record", async () => {
// A MANAGE grant on an adult's own record is not a route into their
// identity. The same refusal the deletion beside it answers with.
const owner = await person("owner");
const delegate = await person("delegate");
const grant = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
signIn(delegate);
const response = await patchProfile(owner.id, { displayName: "Mine" });
expect(response.status).toBe(404);
});
it("refuses a session that has not proved a second factor", async () => {
const { guardian, profile } = await guardianAndProfile();
const prisma = getPrismaClient();
await prisma.session.update({
where: { id: guardian.sessionId },
data: { mfaVerifiedAt: null },
});
signIn(guardian);
const response = await patchProfile(profile.id, { displayName: "Robin" });
// The gate creation and deletion carry, unconditionally. The card renders
// this as "confirm your second factor", not as a failure.
expect(response.status).toBe(401);
expect((await response.json()).meta?.errorCode).toBe(
"auth.stepup.required",
);
});
it("lets a second Guardian edit the record they were invited to", async () => {
const { guardian, profile } = await guardianAndProfile();
const second = await person("second");
const invitation = await inviteGrant({
grantorId: profile.id,
granteeId: second.id,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: invitation.id, granteeId: second.id });
signIn(second);
const response = await patchProfile(profile.id, { displayName: "Robin" });
expect(response.status).toBe(200);
// The creator is not privileged over anybody else who looks after it.
expect(guardian.id).not.toBe(second.id);
});
});