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(/