From f521b03c351bf7f5100ec64c02d27eb771ead964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 00:01:50 +0200 Subject: [PATCH 01/55] feat: let a guardian edit a managed record, and record its sex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de.json | 12 + messages/en.json | 12 + messages/es.json | 12 + messages/fr.json | 12 + messages/it.json | 12 + messages/ko.json | 12 + messages/pl.json | 12 + src/app/api/managed-profiles/[id]/route.ts | 127 +++++- src/app/api/managed-profiles/route.ts | 17 +- .../managed-profile-affordances.test.tsx | 27 +- .../settings/access/managed-profile-card.tsx | 37 +- .../access/managed-profile-create-form.tsx | 81 +++- .../access/managed-profile-edit-form.tsx | 361 ++++++++++++++++++ src/lib/managed-profiles/create.ts | 7 + src/lib/managed-profiles/lifecycle.ts | 185 +++++++++ src/lib/openapi/routes/account-sharing.ts | 112 +++++- src/lib/queries/use-managed-profiles.ts | 115 +++++- src/lib/query-keys/sharing.ts | 9 + src/lib/validations/managed-profiles.ts | 78 +++- .../integration/managed-profile-edit.test.ts | 353 +++++++++++++++++ 20 files changed, 1535 insertions(+), 58 deletions(-) create mode 100644 src/components/settings/access/managed-profile-edit-form.tsx create mode 100644 tests/integration/managed-profile-edit.test.ts diff --git a/messages/de.json b/messages/de.json index eef3089fe..5c326a8f0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index e75e342a8..8c1e7452a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 91f6187d8..a2244e52b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 23921c63d..38342e4fa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index e17e31091..22784cfb7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/ko.json b/messages/ko.json index 66d228404..3c96f69dd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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} 님에 대해 기록한 모든 것이 프로필과 함께 사라져요. 측정값, 약, 문서, 메모 전부요. 되돌릴 수 없어요.", diff --git a/messages/pl.json b/messages/pl.json index 7ec4d5cbc..1ab5e3098 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ąć.", diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts index e952f5f80..6d4deeb12 100644 --- a/src/app/api/managed-profiles/[id]/route.ts +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -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; } diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts index ddc059d68..693a7af47 100644 --- a/src/app/api/managed-profiles/route.ts +++ b/src/app/api/managed-profiles/route.ts @@ -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); }); diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index ab4b97b6f..543561532 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -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(/