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