diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d5b69b924..82bf19a9d 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -18200,7 +18200,8 @@ paths:
summary: The signed-in account, and what sharing lets it do
description: "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose
record is open, and the way back out all read it, so a refusal here would strand a switched session. It is
- therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches
+ therefore an ACTOR surface: it serves the caller's own rows — apart from `modules` and `cycleTrackingEnabled`,
+ which answer for the record the session is inside, masked to what the grant opens — and a request that attaches
`X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that
wants a record's data sends the selector on the read that needs it, never on this call."
responses:
@@ -18504,6 +18505,89 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/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."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: The record as it stands.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ManagedProfileEnvelope"
+ "401": *a1
+ "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:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
+ 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. Ten an
+ hour per caller, the ceiling creation carries. Answers with the whole record, so a client re-renders from the
+ server's version rather than from what it sent."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateManagedProfileRequest"
+ responses:
+ "200":
+ description: The record as it now stands.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ManagedProfileEnvelope"
+ "401":
+ description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation and deletion.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/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:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429":
+ description: More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch
+ on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
delete:
tags:
- Account sharing
@@ -28096,6 +28180,14 @@ components:
type: string
minLength: 1
maxLength: 64
+ gender:
+ anyOf:
+ - type: string
+ enum:
+ - MALE
+ - FEMALE
+ - OTHER
+ - type: "null"
required:
- displayName
- locale
@@ -28105,12 +28197,58 @@ components:
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
+ 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."
+ UpdateManagedProfileRequest:
+ type: object
+ properties:
+ displayName:
+ type: string
+ minLength: 1
+ maxLength: 80
+ dateOfBirth:
+ anyOf:
+ - type: string
+ format: date
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
+ - type: "null"
+ locale:
+ type: string
+ enum:
+ - de
+ - en
+ - es
+ - fr
+ - it
+ - pl
+ - ko
+ timezone:
+ type: string
+ minLength: 1
+ maxLength: 64
+ gender:
+ anyOf:
+ - type: string
+ enum:
+ - MALE
+ - FEMALE
+ - OTHER
+ - type: "null"
+ additionalProperties: false
+ 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."
InviteManagedProfileGuardianRequest:
type: object
properties:
@@ -28375,10 +28513,15 @@ components:
vaccinations:
type: boolean
additionalProperties: false
- required:
- - modulePreferences
+ cycleTrackingEnabled:
+ type: boolean
additionalProperties: false
- description: Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value.
+ description: "Partial module toggle. `modulePreferences` is merged onto the record's current preferences; omitted keys
+ keep their value. `cycleTrackingEnabled` is separate because `cycle` is a DELEGATED module: its state lives in
+ the record's own cycle profile and `modulePreferences.cycle` is refused, so this is the field that turns cycle
+ tracking on or off for the record — an explicit boolean overrides the default the record's sex would derive.
+ Both fields are optional and at least one is required; an empty body is a 422 rather than a save that changed
+ nothing."
RecordNotificationSettingsPatch:
type: object
properties:
@@ -43832,9 +43975,13 @@ components:
- nutrients
- vaccinations
additionalProperties: false
- description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means
- the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`;
- `coach` mirrors the resolved per-user opt-out + operator master flag.
+ description: Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface
+ should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach`
+ mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD
+ the session is inside rather than for the caller, because every surface it gates shows the record's data, and it
+ is masked to the sections the active grant opens — a module outside them reads `false` rather than the record's
+ true state. With no switch — which is every native request, since the Bearer transport carries none — the two
+ are the same account, nothing is masked and the payload is unchanged.
PatchModulesResponse:
type: object
properties:
@@ -65735,9 +65882,13 @@ components:
- recordSession
additionalProperties: {}
description: "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."
+ Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the
+ identity and preference fields still describe the CALLER, because display preferences belong to the person at
+ the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and
+ `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and
+ they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it
+ rather than the record's true state. With no switch — which is every native request, since the Bearer transport
+ carries none — the two are the same account and nothing is masked."
AccountAccess:
type: object
properties:
@@ -66360,13 +66511,31 @@ components:
description: 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:
- type: string
+ anyOf:
+ - type: string
+ - type: "null"
dateOfBirth:
anyOf:
- type: string
- type: "null"
+ description: "`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:
+ anyOf:
+ - type: string
+ enum:
+ - MALE
+ - FEMALE
+ - OTHER
+ - type: "null"
+ description: "`User.gender` for this record. Null means not recorded, which is a real answer and not a missing one."
locale:
- type: string
+ anyOf:
+ - type: string
+ - type: "null"
+ description: 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:
type: string
recordKind:
@@ -66378,15 +66547,16 @@ components:
- id
- displayName
- dateOfBirth
+ - gender
- locale
- timezone
- recordKind
additionalProperties: false
- 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."
+ description: "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."
DeletedManagedProfileEnvelope:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index 6256bc25b..660baafd0 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -10280,6 +10280,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",
@@ -10289,6 +10295,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 3ccc18318..580caf345 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -10280,6 +10280,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",
@@ -10289,6 +10295,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 7ba0835c3..a05ee2038 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -10280,6 +10280,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",
@@ -10289,6 +10295,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 047d580a1..4fc3bbc1d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -10280,6 +10280,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",
@@ -10289,6 +10295,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 c8b4e49d4..879cb849e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -10280,6 +10280,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",
@@ -10289,6 +10295,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 4eb23051d..6debcc9d0 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -10280,6 +10280,12 @@
"dobHint": "적고 싶지 않으면 비워 두세요. 연도로 뭔가를 추측하지는 않아요.",
"localeLabel": "이 기록의 언어",
"localeHint": "이 기록에 적히는 글의 언어예요. 내 화면은 내 언어 그대로예요.",
+ "genderLabel": "성별(선택)",
+ "genderHint": "이 기록에서 주기 추적을 처음에 켜둘지 결정합니다. 언제든 바꿀 수 있습니다.",
+ "genderNone": "지정 안 함",
+ "genderMale": "남성",
+ "genderFemale": "여성",
+ "genderOther": "기타",
"timezoneHint": "이 기록의 하루를 재는 기준 시간대예요.",
"timezoneInvalid": "이 브라우저가 모르는 시간대예요.",
"create": "프로필 만들기",
@@ -10289,6 +10295,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 ae08e819e..cb4ea814c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -10280,6 +10280,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",
@@ -10289,6 +10295,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/__tests__/record-settings-configuration.test.ts b/src/__tests__/record-settings-configuration.test.ts
index a881aaabf..2bbf98387 100644
--- a/src/__tests__/record-settings-configuration.test.ts
+++ b/src/__tests__/record-settings-configuration.test.ts
@@ -23,7 +23,7 @@ describe("managed record settings configuration contract", () => {
"timeFormat",
"dateFormat",
],
- modules: ["modulePreferences"],
+ modules: ["modulePreferences", "cycleTrackingEnabled"],
notifications: ["moodReminderEnabled", "notificationPreferences"],
thresholds: ["overrides"],
coach: ["disableCoach", "preferences"],
@@ -34,6 +34,13 @@ describe("managed record settings configuration contract", () => {
it.each([
["profile", { email: "not-allowed@example.test" }],
["modules", { role: "ADMIN" }],
+ // The delegated key by its module name, which is the mistake a stale
+ // client makes. It has to stay a refusal: accepting it would persist an
+ // inert `false` behind a green "saved" and change nothing.
+ ["modules", { modulePreferences: { cycle: false } }],
+ // An empty patch. It parses as a strict object and would audit a change
+ // nobody made.
+ ["modules", {}],
[
"notifications",
{
@@ -63,6 +70,11 @@ describe("managed record settings configuration contract", () => {
},
],
["modules", { modulePreferences: { mood: false } }],
+ // v1.38.14 (#939) — the delegated key, sent on its own. `cycle` is not a
+ // writable module preference and never will be: its user-layer state is
+ // the record's own cycle profile, so the family names the real column
+ // rather than a blob entry the gate ignores.
+ ["modules", { cycleTrackingEnabled: false }],
[
"notifications",
{
diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx
index 8610e31de..93ac58b02 100644
--- a/src/__tests__/shared-record-navigation.test.tsx
+++ b/src/__tests__/shared-record-navigation.test.tsx
@@ -88,19 +88,43 @@ describe("shared-record navigation", () => {
).toEqual(["/checkups"]);
});
- it("uses the active record scope instead of the actor's module flags", () => {
- const actorModules = {
- medications: false,
- labs: false,
- mood: false,
- mentalHealth: false,
- cycle: false,
- illness: false,
- inboundDocuments: false,
- };
+ /**
+ * Scope and modules are two filters and they answer different questions.
+ *
+ * This case used to assert that the module map was IGNORED inside a shared
+ * record, and the reason was sound at the time: the map `GET /api/auth/me`
+ * published was the ACTOR's, which describes their own dashboard, so letting
+ * it hide a domain the record had granted would have been the actor's
+ * preference deciding somebody else's navigation.
+ *
+ * v1.38.14 (#939) removed the premise rather than the filter. The payload
+ * now resolves the map for the RECORD the session is inside, so the map is
+ * the record's own answer to "what does this record track" — and honouring
+ * it is what makes a guardian's Modules toggle visible inside the profile it
+ * was set for. The scope still decides which doors the grant opens; the map
+ * decides which of them the record uses at all.
+ */
+ it("applies the record's own module map on top of its scope", () => {
+ const scope = ["medications", "labs"] as const;
expect(
- visibleNavDestinations(actorModules, true, true, ["medications"]).map(
+ visibleNavDestinations({}, true, true, [...scope]).map(
+ (destination) => destination.href,
+ ),
+ ).toEqual(["/medications", "/labs"]);
+
+ // The record tracks medications and not labs. Both are in scope; one door
+ // is offered.
+ expect(
+ visibleNavDestinations({ labs: false }, true, true, [...scope]).map(
+ (destination) => destination.href,
+ ),
+ ).toEqual(["/medications"]);
+
+ // And the scope still binds: a module the record tracks is not a door the
+ // grant opens.
+ expect(
+ visibleNavDestinations({}, true, true, ["medications"]).map(
(destination) => destination.href,
),
).toEqual(["/medications"]);
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 425bfa33a..8f4b63a3c 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -9,12 +9,37 @@
* shell would 403 the moment a switch was on.
*
* So every field below is still the CALLER's own — their preferences, their
- * modules, their identity — and the one field about the switch,
- * `accountAccess`, says so explicitly. Nothing here ever renders the owner's
- * data; a delegate reading their own display preferences while inside
- * somebody else's record is the correct behaviour and the reason the mode
- * exists (design §4: locale, theme and layout belong to the person, not the
- * record).
+ * identity — and the one field about the switch, `accountAccess`, says so
+ * explicitly. Nothing here ever renders the owner's health data; a delegate
+ * reading their own display preferences while inside somebody else's record is
+ * the correct behaviour and the reason the mode exists (design §4: locale,
+ * theme and layout belong to the person, not the record).
+ *
+ * TWO fields are the exception, and the exception is the rule applied rather
+ * than a hole in it: `modules` and `cycleTrackingEnabled` resolve for the
+ * RECORD the session is inside, not for the actor (#939). They are not display
+ * preferences. They say what this record tracks — and every consumer of them
+ * gates a surface that shows the RECORD's data: the navigation, the dashboard
+ * tiles, the insights sections, quick-add. Publishing the actor's map while
+ * switched meant a Guardian who turned Cycle off for the child still saw a
+ * Cycle destination in the child's record, and a Guardian who tracks it saw
+ * one there whether or not the child did — the toggle in the record's own
+ * settings wrote a column nothing on screen was reading. Design §4 is about
+ * whose eyes are on the screen; this is about whose record is on it.
+ *
+ * Those two fields are then MASKED to the sections the active grant names.
+ * `accountAccess.active` is set for any live grant, level and scope included,
+ * so the record scoping on its own would hand a delegate scoped to
+ * `measurements` the owner's cycle, screener, illness and nutrient
+ * configuration — and the cycle flag is a derivation of the owner's recorded
+ * sex. A section the grant does not open answers `false`, which is the answer
+ * a module that is off already gives and the answer the browser's own route
+ * inventory has always acted on. An unscoped grant names every section and is
+ * unaffected.
+ *
+ * The own-record case is byte-identical: with no switch the active record IS
+ * the caller, there is no grant to narrow to, and every value below is
+ * resolved from the same row it always was.
*/
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
@@ -31,6 +56,10 @@ import {
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
+import {
+ maskModulesToSections,
+ sectionsOpen,
+} from "@/lib/sharing/module-disclosure";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
export const dynamic = "force-dynamic";
@@ -41,26 +70,54 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.me" } });
- // Two independent awaits, overlapped: the onboarding-cookie resync
- // (v1.4.22 W5 Sr-H1 — fall-back for legacy sessions that predate the
- // cookie; new sessions anchor it inside `createSession`) touches only
- // the cookie store, while the cycle-profile read (v1.15.0 — resolved
- // cycle-tracking gate; no row is forced, a NULL toggle derives from
- // gender) is a Postgres round-trip. Running them sequentially added
- // the cookie hop to every /me — and /me sits on every app boot path.
- const [, , cycleProfile, modules, moduleAvailability, accountAccess] =
+ // Overlapped, not sequential: the two cookie resyncs (v1.4.22 W5 Sr-H1 —
+ // the onboarding fall-back for sessions that predate the cookie; new ones
+ // anchor it inside `createSession`) touch only the cookie store, while the
+ // access resolution is a Postgres round-trip. Running them one after the
+ // other added the cookie hop to every /me, and /me sits on every app boot.
+ const [, , accountAccess] = await Promise.all([
+ setOnboardingPendingCookie(user.onboardingCompletedAt == null),
+ // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
+ // boot, mirroring the onboarding-cookie resync. A locally edited cookie is
+ // corrected here; an account that enrols (or has the policy lifted) loses
+ // the redirect on the next /me read.
+ syncMfaEnrollCookie(user.id, {
+ totpConfirmedAt: user.totpConfirmedAt,
+ mfaEnforced: user.mfaEnforced,
+ }),
+ // v1.36.0 — which records this caller may open, which one they are
+ // inside, and what they may do there. Every value resolved server-side;
+ // see the module docblock for why none of it is left to the client.
+ resolveAccountAccess(auth),
+ ]);
+
+ // The record the two module fields answer for, and the reason this read is
+ // sequenced after the block above rather than inside it: the active record
+ // has to be the GRANT-VALIDATED one. The session row's own selector is not
+ // that — it is a stamp, and a stamp whose grant was revoked a minute ago
+ // still names an account. `resolveAccountAccess` is what checks the live
+ // grant, so its answer is what may pick whose configuration is published.
+ // The cost is one round-trip on a hot path, paid on every /me; publishing a
+ // revoked record's configuration would be paid once, by somebody else.
+ const recordId = accountAccess.active?.accountId ?? user.id;
+
+ // What the grant opens, and therefore what the two record fields below may
+ // say. `null` is the whole record — the answer for an unscoped grant and for
+ // no switch at all, and the reason the own-record payload is untouched.
+ const sections = accountAccess.active?.sections ?? null;
+
+ const [record, cycleProfile, resolvedModules, moduleAvailability] =
await Promise.all([
- setOnboardingPendingCookie(user.onboardingCompletedAt == null),
- // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
- // boot, mirroring the onboarding-cookie resync. A locally edited cookie is
- // corrected here; an account that enrols (or has the policy lifted) loses
- // the redirect on the next /me read.
- syncMfaEnrollCookie(user.id, {
- totpConfirmedAt: user.totpConfirmedAt,
- mfaEnforced: user.mfaEnforced,
- }),
+ // The actor's own row is already in hand; a switched session needs the
+ // record's `gender`, which is the column the cycle gate derives from.
+ recordId === user.id
+ ? Promise.resolve({ gender: user.gender })
+ : prisma.user.findUnique({
+ where: { id: recordId },
+ select: { gender: true },
+ }),
prisma.cycleProfile.findUnique({
- where: { userId: user.id },
+ where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
@@ -68,19 +125,33 @@ export const GET = apiHandler(async () => {
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
- resolveModuleMap(user.id),
+ resolveModuleMap(recordId),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
+ // Server-WIDE, so it is the same map for every record and is not
+ // re-scoped here.
getOperatorModuleAvailability(),
- // v1.36.0 — which records this caller may open, which one they are
- // inside, and what they may do there. Every value resolved server-side;
- // see the module docblock for why none of it is left to the client.
- resolveAccountAccess(auth),
]);
- const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
+
+ // The masking step, and the one place the record scoping is narrowed rather
+ // than resolved. `active` is set for ANY live grant — a READ grant scoped to
+ // `measurements` included — so publishing the record's whole map would tell
+ // a delegate whether the owner tracks their cycle, their screeners, their
+ // illness episodes or their supplement intake, none of which their grant
+ // opens. A closed section reads `false`, the same answer a module that is
+ // off already gives, and the navigation the client builds from it drops
+ // exactly the doors `isSharedRecordPathPresentable` drops anyway.
+ const modules = maskModulesToSections(resolvedModules, sections);
+ // The cycle flag is masked on its own rather than read off `modules.cycle`:
+ // that key also carries the operator's server-wide availability, and this
+ // field never has. Same section, same answer, without borrowing a second
+ // layer's decision.
+ const cycleTrackingEnabled =
+ sectionsOpen(sections, "cycle") &&
+ isCycleEnabled(record?.gender ?? null, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
@@ -157,7 +228,11 @@ export const GET = apiHandler(async () => {
insurerIkNumber: user.insurerIkNumber ?? null,
insuranceNumber,
// v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS
- // hides the whole cycle tab when this is false.
+ // hides the whole cycle tab when this is false. v1.38.14 — resolved for
+ // the ACTIVE RECORD, so a switched browser hides the tab the record does
+ // not track rather than the one the actor does not (#939), and `false`
+ // whenever the active grant does not name the `cycle` section. Unchanged
+ // on the native transport, which carries no switch.
cycleTrackingEnabled,
// v1.18.0 — module enable/disable map. `{ : boolean }`
// for every toggleable module; `false` means the module is OFF and
@@ -165,6 +240,8 @@ export const GET = apiHandler(async () => {
// …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the
// resolved `disableCoach` + operator master flag, so this map is the
// single thing a client needs to gate every secondary domain.
+ // v1.38.14 — resolved for the ACTIVE RECORD and masked to the sections
+ // the active grant opens; see the module docblock.
modules,
// v1.18.0 — operator-layer availability per toggleable module. `false`
// ⇒ the operator turned the module off server-wide (off for every
diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts
index e952f5f80..b713e7ad1 100644
--- a/src/app/api/managed-profiles/[id]/route.ts
+++ b/src/app/api/managed-profiles/[id]/route.ts
@@ -3,17 +3,166 @@ 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 { checkRateLimit } from "@/lib/rate-limit";
+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);
+ },
+);
+
+/**
+ * The ceiling the create sibling carries, on the act that changes what it
+ * created. Ten an hour per caller: a person cannot mistype their way into ten
+ * edits of a record's identity, and the number matching creation and guardian
+ * invitation keeps one family answering one way.
+ *
+ * The GET beside it stays unlimited, matching the guardian roster read next to
+ * it: both are cheap identity reads a Guardian may already make, and neither
+ * family sibling has ever carried a read bucket.
+ */
+const UPDATE_LIMIT = 10;
+const UPDATE_WINDOW_MS = 60 * 60 * 1000;
+
+/**
+ * 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);
+ // Before the id is read, exactly as the create sibling does it. The cheap
+ // refusal has to come first here for a second reason: `updateManagedProfile`
+ // opens a transaction and takes an advisory lock on the id it was HANDED
+ // before it decides the caller is not a Guardian of it, so an unthrottled
+ // caller can name any id they like and make the database hold a lock about
+ // it. Same ceiling as creating a record and inviting a Guardian — the three
+ // acts are one family and answering the same abuse differently is the
+ // asymmetry nobody notices until it is used.
+ const rateLimit = await checkRateLimit(
+ `managed-profile:update:${user.id}`,
+ UPDATE_LIMIT,
+ UPDATE_WINDOW_MS,
+ );
+ if (!rateLimit.allowed) {
+ return apiError("Too many profile edits, try again later", 429);
+ }
+ 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 +172,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/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts
index c18bf3919..089715897 100644
--- a/src/app/api/record-settings/[family]/route.ts
+++ b/src/app/api/record-settings/[family]/route.ts
@@ -30,6 +30,8 @@ import {
parseManagedRecordSettingsPatch,
resolveGuardianRecordSettingsAccess,
} from "@/lib/record-settings";
+import { isCycleEnabled } from "@/lib/cycle/gate";
+import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import {
resolveInsightsLayout,
serializeInsightsLayout,
@@ -101,13 +103,23 @@ export const GET = apiHandler(
dateFormat: record.dateFormat,
};
break;
- case "modules":
+ case "modules": {
+ // The delegated key needs its real source, which is not the module
+ // blob. `isCycleEnabled` resolves the record's own opt-in against its
+ // sex exactly as `/api/auth/me` does for the account itself, so the
+ // switch a guardian sees is the state the gate will answer with.
+ const cycleProfile = await prisma.cycleProfile.findUnique({
+ where: { userId: access.recordId },
+ select: { cycleTrackingEnabled: true },
+ });
settings = {
modulePreferences: managedModulePreferencesFrom(
record.modulePreferencesJson,
),
+ cycleTrackingEnabled: isCycleEnabled(record.gender, cycleProfile),
};
break;
+ }
case "notifications": {
const preferences = parseNotificationPrefs(record.notificationPrefs);
settings = {
@@ -233,19 +245,50 @@ export const PATCH = apiHandler(
const patch = parseManagedRecordSettingsPatch("modules", body);
const current = await prisma.user.findUnique({
where: { id: access.recordId },
- select: { modulePreferencesJson: true },
+ select: { modulePreferencesJson: true, gender: true },
});
const modulePreferences = {
...managedModulePreferencesFrom(current?.modulePreferencesJson),
...patch.modulePreferences,
};
- await prisma.user.update({
- where: { id: access.recordId },
- data: { modulePreferencesJson: toJson(modulePreferences) },
+ if (patch.modulePreferences !== undefined) {
+ await prisma.user.update({
+ where: { id: access.recordId },
+ data: { modulePreferencesJson: toJson(modulePreferences) },
+ });
+ }
+ // The delegated key writes its own column. An explicit boolean is
+ // what the cycle gate honours over the sex-derived default, so a
+ // guardian turning Cycle off for a record whose sex says otherwise
+ // is recorded as the deliberate answer it is.
+ if (patch.cycleTrackingEnabled !== undefined) {
+ await getOrCreateCycleProfile(access.recordId);
+ await prisma.cycleProfile.update({
+ where: { userId: access.recordId },
+ data: { cycleTrackingEnabled: patch.cycleTrackingEnabled },
+ });
+ }
+ const cycleProfile = await prisma.cycleProfile.findUnique({
+ where: { userId: access.recordId },
+ select: { cycleTrackingEnabled: true },
});
invalidateUserHealthScore(access.recordId);
- settings = { modulePreferences };
- changed = Object.keys(patch.modulePreferences);
+ settings = {
+ modulePreferences,
+ cycleTrackingEnabled: isCycleEnabled(
+ current?.gender ?? null,
+ cycleProfile,
+ ),
+ };
+ // The module keys themselves, not the wrapper field: the record's
+ // trail should say which module moved. `cycleTrackingEnabled` is
+ // named as itself because that is the column it writes.
+ changed = [
+ ...Object.keys(patch.modulePreferences ?? {}),
+ ...(patch.cycleTrackingEnabled !== undefined
+ ? ["cycleTrackingEnabled"]
+ : []),
+ ];
break;
}
case "notifications": {
diff --git a/src/components/layout/__tests__/nav-model-shared-record.test.ts b/src/components/layout/__tests__/nav-model-shared-record.test.ts
index 431106898..9da920c9e 100644
--- a/src/components/layout/__tests__/nav-model-shared-record.test.ts
+++ b/src/components/layout/__tests__/nav-model-shared-record.test.ts
@@ -89,6 +89,32 @@ describe("the destination list under a switch", () => {
});
});
+describe("a module the RECORD does not track", () => {
+ /**
+ * #939 — the map `GET /api/auth/me` publishes is the RECORD's while a
+ * session is switched into one, so this filter is what makes a guardian's
+ * toggle visible. Before it, turning Cycle off for a profile left the Cycle
+ * door standing in that profile's own navigation, and the toggle wrote a
+ * column nothing on screen was reading.
+ */
+ it("drops its destination inside the record", () => {
+ const withCycle = visibleNavDestinations(ALL_MODULES, true, true, null).map(
+ (d) => d.href,
+ );
+ expect(withCycle).toContain("/cycle");
+
+ const withoutCycle = visibleNavDestinations(
+ { ...ALL_MODULES, cycle: false },
+ true,
+ true,
+ null,
+ ).map((d) => d.href);
+ expect(withoutCycle).not.toContain("/cycle");
+ // Only that one door: a module toggle is not a scope change.
+ expect(withoutCycle).toContain("/measurements");
+ });
+});
+
describe("the utility tail under a switch", () => {
it("disappears entirely", () => {
// Settings holds credentials, integrations, notification channels and
diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts
index cc3059dcb..300dcec0e 100644
--- a/src/components/layout/nav-model.ts
+++ b/src/components/layout/nav-model.ts
@@ -342,13 +342,22 @@ function isNavDestinationVisible(
) {
return false;
}
- // A shared record's server-resolved scope decides which health-domain doors
- // exist. The actor's module preferences describe their own dashboard and
- // must not hide a domain the target record explicitly granted.
- if (sharedRecord) return true;
- if (d.sharedRecordOnly) return false;
+ // The inverse marker: a destination that exists ONLY inside somebody else's
+ // record (the read-only profile summary). Guarded on `!sharedRecord` rather
+ // than reached only through the shared arm, which is what it relied on
+ // before the module gate below started applying to a shared record too.
+ if (!sharedRecord && d.sharedRecordOnly) return false;
if (!d.requiresModule) return true;
if (!mounted) return false;
+ // The module map, and whose it is, is the whole point. A shared record used
+ // to skip this line: the grant's scope decided which doors existed, and the
+ // module map was the ACTOR's, which describes their own dashboard and must
+ // not hide a domain the record granted. `GET /api/auth/me` now resolves the
+ // map for the RECORD the session is inside (#939), so the objection is gone
+ // and the line is the one that makes a guardian's toggle visible — without
+ // it, turning Cycle off for a profile left the Cycle door standing in that
+ // profile's own navigation. Scope decides which doors the grant opens;
+ // this decides which of them the record tracks at all.
return modules?.[d.requiresModule] !== false;
}
diff --git a/src/components/settings/__tests__/managed-record-settings-section.test.tsx b/src/components/settings/__tests__/managed-record-settings-section.test.tsx
index 88f7941d0..4b58f4e43 100644
--- a/src/components/settings/__tests__/managed-record-settings-section.test.tsx
+++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx
@@ -5,7 +5,10 @@ import { I18nProvider } from "@/lib/i18n/context";
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
import { ALL_METRICS } from "@/lib/validations/thresholds";
-import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
+import {
+ managedModulesPatch,
+ ManagedRecordSettingsForm,
+} from "../managed-record-settings-section";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
@@ -201,3 +204,60 @@ describe("managed record settings forms", () => {
},
);
});
+
+describe("what a managed Modules save sends", () => {
+ const moduleKeys = Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS);
+
+ /** The form as it stands after somebody flips one switch. */
+ const formWith = (checked: Record) => {
+ const form = new FormData();
+ for (const [name, on] of Object.entries(checked)) {
+ if (on) form.set(name, "on");
+ }
+ return form;
+ };
+
+ it("omits the cycle flag when only a direct module moved", () => {
+ // The record tracks cycles and the guardian is turning Labs off. Sending
+ // the cycle switch's position here would write the sex-derived default as
+ // an explicit answer and freeze it against a later change of sex.
+ const settings = {
+ modulePreferences: {},
+ cycleTrackingEnabled: true,
+ };
+ const checked = Object.fromEntries(
+ moduleKeys.map((key) => [key, key !== "labs"]),
+ );
+
+ const patch = managedModulesPatch(
+ settings,
+ formWith({ ...checked, cycleTrackingEnabled: true }),
+ );
+
+ expect(patch.modulePreferences.labs).toBe(false);
+ expect("cycleTrackingEnabled" in patch).toBe(false);
+ });
+
+ it("sends the cycle flag when the cycle switch itself moved", () => {
+ const patch = managedModulesPatch(
+ { modulePreferences: {}, cycleTrackingEnabled: true },
+ formWith(Object.fromEntries(moduleKeys.map((key) => [key, true]))),
+ );
+
+ expect(patch.cycleTrackingEnabled).toBe(false);
+ });
+
+ it("sends the cycle flag when a record with none turns it on", () => {
+ // `cycleTrackingEnabled` absent is the record that never answered; the
+ // switch opens off, so turning it on is a change and has to ride.
+ const patch = managedModulesPatch(
+ { modulePreferences: {} },
+ formWith({
+ ...Object.fromEntries(moduleKeys.map((key) => [key, true])),
+ cycleTrackingEnabled: true,
+ }),
+ );
+
+ expect(patch.cycleTrackingEnabled).toBe(true);
+ });
+});
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index 8dba31062..01ba0b83d 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -20,9 +20,27 @@ vi.mock("next/navigation", () => ({
const mockModulesRef: { value: Record | undefined } = {
value: undefined,
};
+// v1.37.0 — the shell also filters by which record the browser is inside.
+// Unlike the module gate above, that filter is NOT hydration-deferred: the
+// server has already resolved which record is active, so it applies on the SSR
+// pass and is assertable from a static render.
+const mockActiveRecordRef: {
+ value: { recordKind: "managed" | "shared"; level: string } | null;
+} = { value: null };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
- user: { id: "u1", role: "USER", modules: mockModulesRef.value },
+ user: {
+ id: "u1",
+ role: "USER",
+ modules: mockModulesRef.value,
+ accountAccess: mockActiveRecordRef.value
+ ? {
+ accounts: [mockActiveRecordRef.value],
+ active: mockActiveRecordRef.value,
+ canSwitch: true,
+ }
+ : null,
+ },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
@@ -42,9 +60,11 @@ function renderShell(props: {
pathname?: string;
locale?: "en" | "de";
modules?: Record;
+ activeRecord?: { recordKind: "managed" | "shared"; level: string } | null;
}) {
mockPathnameRef.value = props.pathname ?? "/settings/account";
mockModulesRef.value = props.modules;
+ mockActiveRecordRef.value = props.activeRecord ?? null;
return renderToStaticMarkup(
@@ -327,6 +347,39 @@ describe("", () => {
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
});
+ describe("inside somebody else's record", () => {
+ /**
+ * #939 — a guardian could turn a module off for the profile they look
+ * after and had to find the page by URL to do it. The section is
+ * classified `managed-guardian`, which is what the shell offers a managed
+ * record; asserting it here is what stops a future narrowing of the filter
+ * from silently taking the destination away again.
+ */
+ it("offers the Modules destination to a guardian on a managed record", () => {
+ const html = renderShell({
+ active: "account",
+ activeRecord: { recordKind: "managed", level: "manage" },
+ });
+ expect(html).toContain('href="/settings/modules"');
+ // Its own configuration is not the record's, and stays away.
+ expect(html).not.toContain('href="/settings/security"');
+ });
+
+ it("does not offer it to an adult delegate holding MANAGE", () => {
+ // A delegate manages somebody's health record, not their account:
+ // modules, thresholds and notification routing stay with the owner.
+ // Integrations remains status-only on a managed record, and unreachable
+ // here — both are `managed-guardian`, which an adult record never opens.
+ const html = renderShell({
+ active: "account",
+ activeRecord: { recordKind: "shared", level: "manage" },
+ });
+ expect(html).not.toContain('href="/settings/modules"');
+ expect(html).not.toContain('href="/settings/integrations"');
+ expect(html).toContain('href="/settings/anamnesis"');
+ });
+ });
+
it("falls back to `account` when the pathname doesn't match a known slug", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
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(/
= [
+ { 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();
diff --git a/src/components/settings/access/managed-profile-edit-form.tsx b/src/components/settings/access/managed-profile-edit-form.tsx
new file mode 100644
index 000000000..2245f61c3
--- /dev/null
+++ b/src/components/settings/access/managed-profile-edit-form.tsx
@@ -0,0 +1,384 @@
+"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 (
+ void profile.refetch()}
+ />
+ );
+ }
+
+ if (!profile.data) {
+ return (
+
+
+ {t("nav.loadingScreen")}
+
+ );
+ }
+
+ // Keyed on WHICH record is open, so the field state below is seeded once per
+ // record rather than through an effect that re-seeds on every refetch. The
+ // key was the query's `dataUpdatedAt`, which did the opposite of what it
+ // claimed: any invalidation of the `managed-profiles` prefix — removing a
+ // guardian from another row, say — refetches this read, moves the timestamp
+ // and remounts the fields, so a half-typed name disappears while somebody is
+ // looking at it. The id is stable for as long as the form is about the same
+ // record, which is exactly as long as the seeding should hold.
+ return (
+
+ );
+}
+
+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 unless they move it, which is why the seed is frozen at mount and
+ // handed to the patch builder as what "moved" is measured against.
+ const [seededLocale] = useState(() =>
+ isLocale(profile.locale) ? profile.locale : actorLocale,
+ );
+ const [locale, setLocale] = useState(seededLocale);
+ const [gender, setGender] = useState(profile.gender);
+ const [timezone, setTimezone] = useState(profile.timezone);
+ const [error, setError] = useState(null);
+
+ const nameIssue = displayNameIssue(displayName);
+ const timezoneIssue = isValidTimezone(timezone)
+ ? null
+ : "recordSharing.managed.timezoneInvalid";
+ const patch = managedProfileEditPatch(
+ profile,
+ {
+ displayName,
+ dateOfBirth,
+ locale,
+ gender,
+ timezone,
+ },
+ seededLocale,
+ );
+ 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 (
+
+ );
+}
+
+/** 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,
+ /**
+ * The language the control opened on, which is the record's own or — for a
+ * record that has none — the actor's. Compared against instead of
+ * `profile.locale` because those two differ exactly when the record has no
+ * language: comparing against the column made every save of a record like
+ * that carry `locale`, so moving only the timezone wrote the actor's
+ * language into somebody else's record.
+ */
+ seededLocale: Locale,
+): Omit | null {
+ const displayName = draft.displayName.trim();
+ const dateOfBirth = draft.dateOfBirth.length > 0 ? draft.dateOfBirth : null;
+ const patch: Omit = {
+ ...(displayName !== (profile.displayName ?? "") ? { displayName } : {}),
+ ...(dateOfBirth !== profile.dateOfBirth ? { dateOfBirth } : {}),
+ ...(draft.locale !== seededLocale ? { 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 arms creation carries plus the one it does not need, and with the same
+ * reasoning: the step-up arm is a gate rather than a failure and must not read
+ * as one, a 429 is a wait rather than a failure and reuses creation's own line,
+ * 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. The 404 is this route's alone: the record can be
+ * ended by another Guardian while the form sits open.
+ */
+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 === 429) return "recordSharing.managed.errorRateLimit";
+ if (err.status === 404) return "recordSharing.managed.editErrorGone";
+ return "recordSharing.managed.editErrorFailed";
+}
diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx
index 3cb4c9878..e612daae2 100644
--- a/src/components/settings/managed-record-settings-section.tsx
+++ b/src/components/settings/managed-record-settings-section.tsx
@@ -484,6 +484,49 @@ function ProfileSettingsForm({
);
}
+/**
+ * What a Modules save actually sends.
+ *
+ * The direct modules ride along whether or not they moved — they are one blob
+ * the route merges, and a key that did not change writes the value it already
+ * had. `cycleTrackingEnabled` is not one of them: it is a DELEGATED module
+ * whose column the cycle gate honours OVER the sex-derived default, so sending
+ * the switch's current position on every save freezes the derivation. A record
+ * created with no recorded sex derives cycle OFF; a guardian who turns Labs off
+ * and saves would write that OFF as an explicit answer, and the record's sex
+ * being set to FEMALE afterwards would then leave cycle off with nothing on
+ * screen to say why. It also put `cycleTrackingEnabled` in the record's audit
+ * trail on every save of any module.
+ *
+ * So it is sent only when it differs from the value the form opened on, which
+ * is what the managed-record edit form's `managedProfileEditPatch` already does
+ * for its own five fields.
+ *
+ * Exported and pure because it is the decision this form makes, and a decision
+ * about what NOT to send is invisible in the rendered markup.
+ */
+export function managedModulesPatch(
+ settings: Record,
+ form: FormData,
+): {
+ modulePreferences: Record;
+ cycleTrackingEnabled?: boolean;
+} {
+ const cycleTrackingEnabled = form.get("cycleTrackingEnabled") === "on";
+ // The same expression the switch is seeded from, so "differs from what the
+ // form opened on" is one statement rather than two that can drift.
+ const loaded = settings.cycleTrackingEnabled === true;
+ return {
+ modulePreferences: Object.fromEntries(
+ Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS).map((key) => [
+ key,
+ form.get(key) === "on",
+ ]),
+ ),
+ ...(cycleTrackingEnabled !== loaded ? { cycleTrackingEnabled } : {}),
+ };
+}
+
function ModulesSettingsForm({
settings,
disabled,
@@ -500,12 +543,7 @@ function ModulesSettingsForm({
function submit(event: FormEvent) {
event.preventDefault();
- const form = new FormData(event.currentTarget);
- onSave({
- modulePreferences: Object.fromEntries(
- entries.map(([key]) => [key, form.get(key) === "on"]),
- ),
- });
+ onSave(managedModulesPatch(settings, new FormData(event.currentTarget)));
}
return (
@@ -523,6 +561,17 @@ function ModulesSettingsForm({
disabled={disabled}
/>
))}
+ {/* v1.38.14 (#939) — Cycle sits below the rest because it is not one of
+ them: it is a DELEGATED module, so its state lives in the record's own
+ cycle profile rather than in the module blob, and it had no row here
+ at all. A guardian could not turn it off for a record whose sex says
+ it should be on, which is the case the issue was opened about. */}
+
);
diff --git a/src/lib/managed-profiles/create.ts b/src/lib/managed-profiles/create.ts
index 431e36373..39087f052 100644
--- a/src/lib/managed-profiles/create.ts
+++ b/src/lib/managed-profiles/create.ts
@@ -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,
diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts
index 6865d3f52..44ddac8ac 100644
--- a/src/lib/managed-profiles/lifecycle.ts
+++ b/src/lib/managed-profiles/lifecycle.ts
@@ -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 {
+ 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 {
+ 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
diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts
index 76826de99..6544917ce 100644
--- a/src/lib/openapi/routes/account-sharing.ts
+++ b/src/lib/openapi/routes/account-sharing.ts
@@ -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
@@ -430,16 +453,29 @@ const accountPayload = z
.meta({
id: "AccountPayload",
description:
- "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.",
+ "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 the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked.",
});
+/**
+ * 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 = {
"/api/auth/me": {
get: {
tags: ["Auth"],
summary: "The signed-in account, and what sharing lets it do",
description:
- "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call.",
+ "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it serves the caller's own rows — apart from `modules` and `cycleTrackingEnabled`, which answer for the record the session is inside, masked to what the grant opens — and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call.",
responses: {
...stdResponses,
"200": {
@@ -676,10 +712,7 @@ export const accountSharingPaths: NonNullable = {
description: "The new record.",
content: {
"application/json": {
- schema: dataEnvelope(
- managedProfileCreated,
- "ManagedProfileEnvelope",
- ),
+ schema: managedProfileEnvelope,
},
},
},
@@ -697,6 +730,72 @@ export const accountSharingPaths: NonNullable = {
},
},
"/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. Ten an hour per caller, the ceiling creation carries. 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 } },
+ },
+ "429": {
+ description:
+ "More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+ },
+ },
delete: {
tags: ["Account sharing"],
summary: "Delete a managed record",
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index 1e8697cd1..90060052e 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -84,7 +84,7 @@ const moduleMapResolved = z
.meta({
id: "ModuleMap",
description:
- "Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag.",
+ "Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD the session is inside rather than for the caller, because every surface it gates shows the record's data, and it is masked to the sections the active grant opens — a module outside them reads `false` rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account, nothing is masked and the payload is unchanged.",
});
const moduleMapEnvelopeInner = z
diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts
index ab891db0e..d8622e60f 100644
--- a/src/lib/openapi/routes/record-settings.ts
+++ b/src/lib/openapi/routes/record-settings.ts
@@ -276,7 +276,7 @@ const recordSettingsPatchRequest = z
MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.modules.meta({
id: "RecordModuleSettingsPatch",
description:
- "Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value.",
+ "Partial module toggle. `modulePreferences` is merged onto the record's current preferences; omitted keys keep their value. `cycleTrackingEnabled` is separate because `cycle` is a DELEGATED module: its state lives in the record's own cycle profile and `modulePreferences.cycle` is refused, so this is the field that turns cycle tracking on or off for the record — an explicit boolean overrides the default the record's sex would derive. Both fields are optional and at least one is required; an empty body is a 422 rather than a save that changed nothing.",
}),
MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.notifications.meta({
id: "RecordNotificationSettingsPatch",
diff --git a/src/lib/queries/use-managed-profiles.ts b/src/lib/queries/use-managed-profiles.ts
index ee87a97cc..eaaed7e26 100644
--- a/src/lib/queries/use-managed-profiles.ts
+++ b/src/lib/queries/use-managed-profiles.ts
@@ -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("/api/managed-profiles", input),
+ apiPost("/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(`/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(
+ `/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 {
+ 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. */
diff --git a/src/lib/query-keys/sharing.ts b/src/lib/query-keys/sharing.ts
index 95b0f4493..09a4f14b5 100644
--- a/src/lib/query-keys/sharing.ts
+++ b/src/lib/query-keys/sharing.ts
@@ -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,
diff --git a/src/lib/record-settings/configuration.ts b/src/lib/record-settings/configuration.ts
index d0fa87ee8..511a5d01f 100644
--- a/src/lib/record-settings/configuration.ts
+++ b/src/lib/record-settings/configuration.ts
@@ -30,7 +30,7 @@ export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = {
"timeFormat",
"dateFormat",
],
- modules: ["modulePreferences"],
+ modules: ["modulePreferences", "cycleTrackingEnabled"],
notifications: ["moodReminderEnabled", "notificationPreferences"],
thresholds: ["overrides"],
coach: ["disableCoach", "preferences"],
@@ -140,7 +140,31 @@ const managedCoachPreferencesSchema = coachPrefsSchema
*/
export const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = {
profile: profilePatchSchema,
- modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(),
+ /**
+ * Two fields because the module foundation has two kinds of key, and only
+ * one of them lives in `modulePreferencesJson`.
+ *
+ * `cycle` is a DELEGATED module: its user-layer state is owned by
+ * `CycleProfile.cycleTrackingEnabled` (NULL derives from `User.gender`), and
+ * `modulePreferencesJson.cycle` is ignored on purpose — which is why
+ * `modulePrefsPatchSchema` refuses the key. A guardian therefore had no way
+ * to turn Cycle off for a record whose sex says it should be on, and no row
+ * for it in the list either: it is simply not a writable key (#939). Naming
+ * the real column here writes the real source of truth rather than minting a
+ * second one that the gate would ignore.
+ *
+ * Both optional so a client may send either alone; at least one required, so
+ * an empty body is a refusal rather than a save that changed nothing.
+ */
+ modules: z
+ .object({
+ modulePreferences: modulePrefsPatchSchema.optional(),
+ cycleTrackingEnabled: z.boolean().optional(),
+ })
+ .strict()
+ .refine((patch) => Object.values(patch).some((v) => v !== undefined), {
+ message: "Name at least one field to change",
+ }),
notifications: z
.object({
moodReminderEnabled: z.boolean().optional(),
diff --git a/src/lib/sharing/module-disclosure.ts b/src/lib/sharing/module-disclosure.ts
new file mode 100644
index 000000000..51a1b51a3
--- /dev/null
+++ b/src/lib/sharing/module-disclosure.ts
@@ -0,0 +1,104 @@
+/**
+ * What a scoped grant is allowed to learn about a record's configuration.
+ *
+ * v1.38.14 made `GET /api/auth/me` publish the ACTIVE RECORD's module map and
+ * cycle flag rather than the actor's, because every surface those two fields
+ * gate shows the record's data. That is right for a whole-record grant and too
+ * generous for a narrowed one: `accountAccess.active` is set for any live
+ * grant, level and scope included, so a delegate scoped to `measurements`
+ * would read whether the owner tracks their cycle, their mental-health
+ * screeners, their illness episodes and their supplement intake — configuration
+ * the grant does not open, and in the cycle flag's case a derivation of the
+ * owner's recorded sex.
+ *
+ * So the map is masked to the sections the grant names, and the mask is the
+ * reason this table can exist beside `scope.ts`'s standing rule that the
+ * sharing vocabulary never converts to the module vocabulary. The rule guards
+ * a direction: a module the owner enabled must never imply a section the owner
+ * shared. Nothing here runs that way. A closed section answers `false` — the
+ * same answer the client already gets for a module that is off — and an open
+ * one answers whatever the gate resolved, never more. No value in this file
+ * can turn a module ON, admit a read, or widen a grant; deleting the table
+ * would leak, not lock.
+ *
+ * `null` means "this module is not one section of a record". Those keys are
+ * masked off for every scoped grant, which is the same answer the browser's
+ * own route inventory gives them: `/`, `/achievements` and the narrative
+ * surfaces read across sections, and a selected-domain grant never opens one.
+ */
+import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
+import type { ShareDomain } from "@/lib/sharing/scope";
+
+/**
+ * The section of a record each module's surfaces read, or `null` when they
+ * read across sections.
+ *
+ * Typed as a total record over {@link MODULE_KEYS}, so a new module cannot
+ * ship without an answer here — and the answer a hurried one gives is at worst
+ * `null`, which discloses nothing.
+ *
+ * The section chosen is the one the browser's route inventory already puts the
+ * module's page under (`src/lib/navigation/shared-record.ts`), so the chrome a
+ * delegate is offered and the configuration they are told about agree.
+ */
+export const MODULE_SHARE_DOMAIN: Readonly<
+ Record
+> = Object.freeze({
+ cycle: "cycle",
+ mood: "mind",
+ sleep: "measurements",
+ glucose: "measurements",
+ workouts: "measurements",
+ recovery: "measurements",
+ labs: "labs",
+ illness: "illness",
+ // Reads across the record: the achievement engine counts every domain.
+ achievements: null,
+ // The assistant surfaces narrate the whole record, which is why a scoped
+ // grant is never offered them.
+ coach: null,
+ insights: null,
+ medications: "medications",
+ // The export assembles every section it is given.
+ doctorReport: null,
+ // Weather and air quality are context for the record as a whole, not one
+ // section of it.
+ environment: null,
+ // The account's own remote endpoint. Not a section of anybody's record.
+ mcp: null,
+ inboundDocuments: "documents",
+ mentalHealth: "mind",
+ nutrients: "measurements",
+ // An immunization history is health background, the section `/vaccinations`
+ // is presented under.
+ vaccinations: "profile",
+});
+
+/** Does a grant carrying these sections open this one? `null` is the whole record. */
+export function sectionsOpen(
+ sections: readonly ShareDomain[] | null,
+ domain: ShareDomain,
+): boolean {
+ return sections === null || sections.includes(domain);
+}
+
+/**
+ * The module map as a grant carrying these sections may read it.
+ *
+ * `null` sections are the whole record and pass the map through untouched,
+ * which keeps the own-record and whole-record payloads byte-identical to what
+ * they were. Anything else answers `false` for every key outside the grant.
+ */
+export function maskModulesToSections(
+ modules: Record,
+ sections: readonly ShareDomain[] | null,
+): Record {
+ if (sections === null) return modules;
+ const masked = {} as Record;
+ for (const key of MODULE_KEYS) {
+ const domain = MODULE_SHARE_DOMAIN[key];
+ masked[key] =
+ domain !== null && sectionsOpen(sections, domain) ? modules[key] : false;
+ }
+ return masked;
+}
diff --git a/src/lib/validations/managed-profiles.ts b/src/lib/validations/managed-profiles.ts
index 59b940144..e582c7c32 100644
--- a/src/lib/validations/managed-profiles.ts
+++ b/src/lib/validations/managed-profiles.ts
@@ -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({
diff --git a/tests/integration/managed-profile-edit.test.ts b/tests/integration/managed-profile-edit.test.ts
new file mode 100644
index 000000000..afb9fdcf0
--- /dev/null
+++ b/tests/integration/managed-profile-edit.test.ts
@@ -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 {
+ 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);
+ // `auditLog` stores `details` as a JSON STRING, not as a Json column.
+ const details = JSON.parse(audit.details ?? "{}") as { changed?: string[] };
+ expect((details.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);
+ });
+});
diff --git a/tests/integration/managed-record-modules.test.ts b/tests/integration/managed-record-modules.test.ts
new file mode 100644
index 000000000..9e0b50a32
--- /dev/null
+++ b/tests/integration/managed-record-modules.test.ts
@@ -0,0 +1,394 @@
+/**
+ * Which record's modules a switched browser reads and writes (#939).
+ *
+ * Two claims, and neither is provable from one end:
+ *
+ * 1. a guardian turning a module off for the profile they look after writes
+ * the PROFILE's state, and their own account is untouched — proved
+ * through the real `PATCH /api/record-settings/modules` handler and the
+ * real rows behind it;
+ * 2. the payload the browser then gates its navigation on answers for the
+ * RECORD. Before this, `GET /api/auth/me` published the ACTOR's module
+ * map while switched, so the toggle wrote a column nothing on screen was
+ * reading: the guardian turned Cycle off for the child and kept seeing a
+ * Cycle destination in the child's record.
+ *
+ * `cycle` carries most of the weight because it is the case the issue was
+ * opened about and the one the module blob cannot express: it is a DELEGATED
+ * module whose user-layer state lives in `CycleProfile.cycleTrackingEnabled`,
+ * so `modulePreferencesJson.cycle` is ignored by the gate and refused by the
+ * schema. A test that wrote the blob and asserted the blob would have passed
+ * against a record whose gate still answered "on".
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NextRequest } from "next/server";
+
+import type { ShareDomain } from "@/lib/sharing/scope";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.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 counter = 0;
+
+async function makeUser(label: string) {
+ const suffix = `${label}-${counter++}`;
+ return getPrismaClient().user.create({
+ data: {
+ username: `mods-${suffix}`,
+ email: `mods-${suffix}@example.test`,
+ displayName: `Mods ${suffix}`,
+ role: "USER",
+ timezone: "Europe/Berlin",
+ locale: "en",
+ // The guardian tracks cycles themselves. That is the whole point: their
+ // own map must not be what the child's record is painted from.
+ gender: "FEMALE",
+ onboardingCompletedAt: new Date(),
+ },
+ });
+}
+
+async function signIn(userId: string) {
+ const session = await getPrismaClient().session.create({
+ data: { userId, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return session;
+}
+
+interface MePayload {
+ modules: Record;
+ cycleTrackingEnabled: boolean;
+ accountAccess: {
+ active: { accountId: string; sections: string[] | null } | null;
+ };
+}
+
+async function readMe(): Promise {
+ const { GET } = await import("@/app/api/auth/me/route");
+ const res = await GET();
+ expect(res.status).toBe(200);
+ return ((await res.json()) as { data: MePayload }).data;
+}
+
+async function patchModules(body: unknown) {
+ const { PATCH } = await import("@/app/api/record-settings/[family]/route");
+ return PATCH(
+ new NextRequest("http://localhost/api/record-settings/modules", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ family: "modules" }) },
+ );
+}
+
+async function readModules() {
+ const { GET } = await import("@/app/api/record-settings/[family]/route");
+ return GET(
+ new NextRequest("http://localhost/api/record-settings/modules", {
+ method: "GET",
+ }),
+ { params: Promise.resolve({ family: "modules" }) },
+ );
+}
+
+/** A guardian signed in and switched into the profile they look after. */
+async function guardianInside(profileGender: string | null = "FEMALE") {
+ const guardian = await makeUser("guardian");
+ const { createManagedProfile } =
+ await import("@/lib/managed-profiles/create");
+ const { profile } = await createManagedProfile({
+ creatorId: guardian.id,
+ displayName: "Managed record",
+ dateOfBirth: null,
+ locale: "en",
+ timezone: "UTC",
+ gender: profileGender,
+ });
+ const session = await signIn(guardian.id);
+ await switchSessionTo(session.id, profile.id);
+ return { guardian, profile, session };
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ counter = 0;
+});
+
+describe("a guardian turning Cycle off for the record they look after", () => {
+ it("writes the record's own cycle profile and leaves their account alone", async () => {
+ const { guardian, profile, session } = await guardianInside();
+
+ const response = await patchModules({ cycleTrackingEnabled: false });
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.data.recordId).toBe(profile.id);
+ expect(body.data.settings.cycleTrackingEnabled).toBe(false);
+
+ const prisma = getPrismaClient();
+ // The delegated module's REAL source, not the module blob — which the
+ // gate ignores for this key and the schema refuses.
+ const written = await prisma.cycleProfile.findUniqueOrThrow({
+ where: { userId: profile.id },
+ });
+ expect(written.cycleTrackingEnabled).toBe(false);
+ expect(
+ await prisma.cycleProfile.findUnique({ where: { userId: guardian.id } }),
+ ).toBeNull();
+
+ // And the gate agrees, which is the claim that matters.
+ const { isModuleEnabled } = await import("@/lib/modules/gate");
+ await expect(isModuleEnabled(profile.id, "cycle")).resolves.toBe(false);
+ await expect(isModuleEnabled(guardian.id, "cycle")).resolves.toBe(true);
+
+ // The guardian's own account, read as themselves, is what it was.
+ await switchSessionTo(session.id, null);
+ const own = await readMe();
+ expect(own.accountAccess.active).toBeNull();
+ expect(own.modules.cycle).toBe(true);
+ expect(own.cycleTrackingEnabled).toBe(true);
+ });
+
+ it("hides the destination in the record the browser is inside", async () => {
+ const { profile } = await guardianInside();
+
+ // Before: the record tracks cycles, because its sex says so.
+ const before = await readMe();
+ expect(before.accountAccess.active?.accountId).toBe(profile.id);
+ expect(before.modules.cycle).toBe(true);
+
+ await patchModules({ cycleTrackingEnabled: false });
+
+ // After: the payload the navigation gates on answers for the RECORD.
+ // `visibleNavDestinations` drops an entry whose `requiresModule` key is
+ // false, so this is the value that removes the Cycle destination.
+ const after = await readMe();
+ expect(after.modules.cycle).toBe(false);
+ expect(after.cycleTrackingEnabled).toBe(false);
+
+ const { visibleNavDestinations } =
+ await import("@/components/layout/nav-model");
+ const hrefs = visibleNavDestinations(after.modules, true, true, null).map(
+ (destination) => destination.href,
+ );
+ expect(hrefs).not.toContain("/cycle");
+ });
+
+ it("makes the module gate refuse the record's cycle routes", async () => {
+ const { profile } = await guardianInside();
+ await patchModules({ cycleTrackingEnabled: false });
+
+ const { requireModuleEnabled } = await import("@/lib/modules/gate");
+ const gate = await requireModuleEnabled(profile.id, "cycle");
+ expect(gate.enabled).toBe(false);
+ if (gate.enabled) throw new Error("expected the gate to refuse");
+ expect(gate.response.status).toBe(403);
+ const refusal = await gate.response.json();
+ expect(refusal.meta?.errorCode).toBe("module.disabled");
+ expect(refusal.meta?.module).toBe("cycle");
+ });
+});
+
+describe("a guardian turning a directly-owned module off", () => {
+ it("writes the record's preference blob and not their own", async () => {
+ const { guardian, profile } = await guardianInside();
+
+ const response = await patchModules({
+ modulePreferences: { mood: false },
+ });
+ expect(response.status).toBe(200);
+ expect((await response.json()).data.settings.modulePreferences.mood).toBe(
+ false,
+ );
+
+ const { isModuleEnabled } = await import("@/lib/modules/gate");
+ await expect(isModuleEnabled(profile.id, "mood")).resolves.toBe(false);
+ await expect(isModuleEnabled(guardian.id, "mood")).resolves.toBe(true);
+
+ const switched = await readMe();
+ expect(switched.modules.mood).toBe(false);
+ });
+});
+
+describe("the module map a switched payload publishes", () => {
+ it("is byte-identical to the unswitched one when there is no switch", async () => {
+ // The do-no-harm claim, and it has to be an equality rather than a spot
+ // check: the record scoping is a change to a field every client gates on.
+ const user = await makeUser("own");
+ await signIn(user.id);
+
+ const payload = await readMe();
+ const { resolveModuleMap } = await import("@/lib/modules/gate");
+ expect(payload.modules).toEqual(await resolveModuleMap(user.id));
+ expect(payload.accountAccess.active).toBeNull();
+ });
+
+ it("reads the record's sex rather than the actor's", async () => {
+ // No explicit opt-in anywhere: the record simply has a sex the cycle gate
+ // derives OFF from, which is the reporter's own suggested fix for a
+ // profile created for a child.
+ const { profile } = await guardianInside("MALE");
+
+ const payload = await readMe();
+ expect(payload.accountAccess.active?.accountId).toBe(profile.id);
+ expect(payload.modules.cycle).toBe(false);
+ expect(payload.cycleTrackingEnabled).toBe(false);
+ });
+});
+
+describe("who may write the record's module map", () => {
+ it("refuses a delegate whose grant is READ", async () => {
+ const guardian = await makeUser("guardian");
+ const reader = await makeUser("reader");
+ const { createManagedProfile } =
+ await import("@/lib/managed-profiles/create");
+ const { profile } = await createManagedProfile({
+ creatorId: guardian.id,
+ displayName: "Managed record",
+ dateOfBirth: null,
+ locale: "en",
+ timezone: "UTC",
+ });
+ const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants");
+ const grant = await inviteGrant({
+ grantorId: profile.id,
+ granteeId: reader.id,
+ access: "READ",
+ scope: null,
+ });
+ await acceptGrant({ grantId: grant.id, granteeId: reader.id });
+ const session = await signIn(reader.id);
+ await switchSessionTo(session.id, profile.id);
+
+ const write = await patchModules({ cycleTrackingEnabled: false });
+ expect(write.status).toBe(403);
+
+ // Not the read either: the record's configuration is guardian-only, and
+ // the refusal is the same at both ends.
+ expect((await readModules()).status).toBe(403);
+
+ const prisma = getPrismaClient();
+ expect(
+ await prisma.cycleProfile.findUnique({ where: { userId: profile.id } }),
+ ).toBeNull();
+ });
+
+ 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
+ // account configuration. The guardian fence is about the managed marker,
+ // not about the level.
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants");
+ const grant = await inviteGrant({
+ grantorId: owner.id,
+ granteeId: delegate.id,
+ access: "MANAGE",
+ scope: null,
+ });
+ await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
+ const session = await signIn(delegate.id);
+ await switchSessionTo(session.id, owner.id);
+
+ expect((await patchModules({ cycleTrackingEnabled: false })).status).toBe(
+ 403,
+ );
+ });
+});
+
+describe("what a SCOPED grant is told about the record's modules", () => {
+ /** An owner who tracks everything, and a delegate holding one section. */
+ async function delegateInside(scope: ShareDomain[] | null) {
+ const owner = await makeUser("owner");
+ const delegate = await makeUser("delegate");
+ const { acceptGrant, inviteGrant } = await import("@/lib/sharing/grants");
+ const grant = await inviteGrant({
+ grantorId: owner.id,
+ granteeId: delegate.id,
+ access: "READ",
+ scope,
+ });
+ await acceptGrant({ grantId: grant.id, granteeId: delegate.id });
+ const session = await signIn(delegate.id);
+ await switchSessionTo(session.id, owner.id);
+ return { owner, delegate };
+ }
+
+ it("answers false for every module outside the granted sections", async () => {
+ // The disclosure the record scoping opened: `active` is set for ANY live
+ // grant, so a delegate holding `measurements` would otherwise read the
+ // owner's cycle, screener, illness and lab configuration — and the cycle
+ // flag derives from the owner's recorded sex.
+ const { owner } = await delegateInside(["measurements"]);
+
+ const payload = await readMe();
+ expect(payload.accountAccess.active?.accountId).toBe(owner.id);
+ expect(payload.accountAccess.active?.sections).toEqual(["measurements"]);
+
+ // The owner really does track these — masked, not merely absent.
+ const { resolveModuleMap } = await import("@/lib/modules/gate");
+ const truth = await resolveModuleMap(owner.id);
+ for (const key of [
+ "cycle",
+ "mood",
+ "mentalHealth",
+ "illness",
+ "labs",
+ "medications",
+ "inboundDocuments",
+ ] as const) {
+ expect(truth[key], `the record's own ${key}`).toBe(true);
+ expect(payload.modules[key], `the delegate's ${key}`).toBe(false);
+ }
+ expect(payload.cycleTrackingEnabled).toBe(false);
+
+ // The granted section still answers honestly, or the mask would be a
+ // blackout rather than a scope.
+ for (const key of ["sleep", "glucose", "workouts", "recovery"] as const) {
+ expect(payload.modules[key], `the delegate's ${key}`).toBe(true);
+ }
+ });
+
+ it("leaves an unscoped grant reading the record's true map", async () => {
+ // `scope: null` is the whole record, which every grant written before
+ // scoping carries. Masking it would break the case the record scoping was
+ // built for.
+ const { owner } = await delegateInside(null);
+
+ const payload = await readMe();
+ expect(payload.accountAccess.active?.sections).toBeNull();
+
+ const { resolveModuleMap } = await import("@/lib/modules/gate");
+ expect(payload.modules).toEqual(await resolveModuleMap(owner.id));
+ expect(payload.cycleTrackingEnabled).toBe(true);
+ });
+});