From 1eb0d22285b9bd939b642e5ead75a132565efaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:25:53 +0200 Subject: [PATCH 01/12] test: add record settings contract - Cover record-keyed DTO and cache behavior\n- Pin actor identity separately from the active record --- .../record-settings-contract.test.ts | 48 +++++++++++++++++++ .../record-settings-authorization.test.ts | 35 ++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/__tests__/record-settings-contract.test.ts create mode 100644 tests/integration/record-settings-authorization.test.ts diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts new file mode 100644 index 000000000..98ea53cb0 --- /dev/null +++ b/src/__tests__/record-settings-contract.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { + assertRecordSettingsAccess, + type RecordSettingsAccess, +} from "@/lib/record-settings/access"; +import { toRecordSettingsDto } from "@/lib/record-settings/dto"; +import { recordSettingsKeys } from "@/lib/query-keys"; + +describe("record settings contract", () => { + it("names the target record in both the DTO and cache key", () => { + const dto = toRecordSettingsDto({ + id: "record-1", + name: "Managed profile", + locale: "de", + timezone: "Europe/Berlin", + recordKind: "managed", + }); + + expect(dto).toEqual({ + record: { + id: "record-1", + displayName: "Managed profile", + locale: "de", + timezone: "Europe/Berlin", + kind: "managed", + }, + }); + expect(recordSettingsKeys.detail("record-1")).toEqual([ + "record-settings", + "record-1", + "detail", + ]); + }); + + it("does not let an adult MANAGE grant use Guardian-only configuration", () => { + const access: RecordSettingsAccess = { + actorId: "adult-manager", + recordId: "managed-record", + recordKind: "managed", + relationship: "adult-manager", + }; + + expect(() => assertRecordSettingsAccess(access, "guardian")).toThrow( + "Guardian configuration is unavailable for this record", + ); + }); +}); diff --git a/tests/integration/record-settings-authorization.test.ts b/tests/integration/record-settings-authorization.test.ts new file mode 100644 index 000000000..15eaaf24c --- /dev/null +++ b/tests/integration/record-settings-authorization.test.ts @@ -0,0 +1,35 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings/access"; + +const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); + +describe("record settings authorization", () => { + it("keeps actor and record separation", async () => { + const [authMeRoute, recordSettingsRoute] = await Promise.all([ + readFile(fromRoot("src/app/api/auth/me/route.ts"), "utf8"), + readFile(fromRoot("src/app/api/record-settings/route.ts"), "utf8"), + ]); + + const access = resolveGuardianRecordSettingsAccess({ + actor: { id: "guardian-1" }, + grantId: "grant-1", + user: { id: "managed-record-1", managedProfileAt: new Date() }, + }); + + expect(access).toMatchObject({ + actorId: "guardian-1", + recordId: "managed-record-1", + recordKind: "managed", + relationship: "guardian", + }); + expect(authMeRoute).toContain("requireAuth"); + expect(authMeRoute).not.toContain("requireGuardianAuth"); + expect(authMeRoute).not.toContain("requireRecordAuth"); + expect(recordSettingsRoute).toContain("requireGuardianAuth"); + expect(recordSettingsRoute).toContain("toRecordSettingsDto"); + }); +}); From 009de7cb5f01d78a45eb4d685c90fcf3d9a803b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:26:44 +0200 Subject: [PATCH 02/12] feat: add record keyed settings - Separate managed record settings from actor identity\n- Limit integration reads to the active managed record --- .../api/record-settings/integrations/route.ts | 51 +++++++++++++++++ src/app/api/record-settings/route.ts | 35 ++++++++++++ src/lib/query-keys/index.ts | 4 ++ src/lib/query-keys/record-settings.ts | 15 +++++ src/lib/record-settings/access.ts | 55 +++++++++++++++++++ src/lib/record-settings/dto.ts | 32 +++++++++++ src/lib/record-settings/index.ts | 6 ++ 7 files changed, 198 insertions(+) create mode 100644 src/app/api/record-settings/integrations/route.ts create mode 100644 src/app/api/record-settings/route.ts create mode 100644 src/lib/query-keys/record-settings.ts create mode 100644 src/lib/record-settings/access.ts create mode 100644 src/lib/record-settings/dto.ts create mode 100644 src/lib/record-settings/index.ts diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts new file mode 100644 index 000000000..79fe386c6 --- /dev/null +++ b/src/app/api/record-settings/integrations/route.ts @@ -0,0 +1,51 @@ +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + getIntegrationStatus, + type IntegrationKey, +} from "@/lib/integrations/status"; +import { annotate } from "@/lib/logging/context"; +import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [ + "withings", + "whoop", + "fitbit", + "nightscout", + "polar", + "oura", + "google-health", + "strava", +]; + +/** + * A managed profile may disclose whether its existing integrations are + * connected, but this endpoint deliberately has no control or credential + * projection. OAuth and provider state cannot safely carry a record selector. + */ +export const GET = apiHandler(async () => { + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + const integrations = await Promise.all( + MANAGED_INTEGRATIONS.map(async (integration) => { + const status = await getIntegrationStatus(access.recordId, integration); + return { + integration: status.integration, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + }; + }), + ); + + annotate({ action: { name: "record-settings.integrations.get" } }); + + return apiSuccess({ recordId: access.recordId, integrations }); +}); diff --git a/src/app/api/record-settings/route.ts b/src/app/api/record-settings/route.ts new file mode 100644 index 000000000..9c18b08e8 --- /dev/null +++ b/src/app/api/record-settings/route.ts @@ -0,0 +1,35 @@ +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + resolveGuardianRecordSettingsAccess, + toRecordSettingsDto, +} from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +/** + * The active managed record's configuration identity. This is intentionally a + * distinct record DTO rather than another actor field on `/api/auth/me`. + */ +export const GET = apiHandler(async () => { + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + + annotate({ action: { name: "record-settings.get" } }); + + return apiSuccess( + toRecordSettingsDto({ + id: access.recordId, + name: context.user.displayName ?? context.user.username, + locale: context.user.locale, + timezone: context.user.timezone, + recordKind: "managed", + }), + ); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 0085b1415..7dd9aec45 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -35,6 +35,7 @@ import { medicationKeys } from "./medications"; import { mentalHealthKeys } from "./mental-health"; import { moodKeys } from "./mood"; import { nutrientKeys } from "./nutrients"; +import { recordSettingsKeys } from "./record-settings"; import { settingsKeys } from "./settings"; import { sharingKeys } from "./sharing"; import { workoutKeys } from "./workouts"; @@ -63,9 +64,12 @@ export const queryKeys = { ...documentKeys, ...customMetrics, ...nutrientKeys, + ...recordSettingsKeys, ...sharingKeys, }; +export { recordSettingsKeys } from "./record-settings"; + /** * v1.36.0 — every read a grant transition can make stale. * diff --git a/src/lib/query-keys/record-settings.ts b/src/lib/query-keys/record-settings.ts new file mode 100644 index 000000000..3f47c26d3 --- /dev/null +++ b/src/lib/query-keys/record-settings.ts @@ -0,0 +1,15 @@ +/** + * Record configuration has its own cache family because it follows the active + * health record, while `authMe()` remains the person at the keyboard. Keeping + * the record id in every member prevents an actor cache cell from being read + * or invalidated as though it belonged to a switched record. + */ +export const recordSettingsKeys = { + root: () => ["record-settings"] as const, + detail: (recordId: string) => + ["record-settings", recordId, "detail"] as const, + integrations: (recordId: string) => + ["record-settings", recordId, "integrations"] as const, + profile: (recordId: string) => + ["record-settings", recordId, "profile"] as const, +}; diff --git a/src/lib/record-settings/access.ts b/src/lib/record-settings/access.ts new file mode 100644 index 000000000..ba1f8c79b --- /dev/null +++ b/src/lib/record-settings/access.ts @@ -0,0 +1,55 @@ +export type RecordSettingsRelationship = + | "guardian" + | "self" + | "adult-manager"; + +export interface RecordSettingsAccess { + actorId: string; + recordId: string; + recordKind: "managed" | "self" | "shared"; + relationship: RecordSettingsRelationship; +} + +/** + * Assert a record-settings capability that is deliberately narrower than a + * general MANAGE grant. A MANAGE grant on an adult's ordinary record is not a + * route into their identity or configuration. + */ +export function assertRecordSettingsAccess( + access: RecordSettingsAccess, + destination: "guardian", +): void { + if ( + destination === "guardian" && + (access.recordKind !== "managed" || access.relationship !== "guardian") + ) { + throw new Error("Guardian configuration is unavailable for this record"); + } +} + +/** + * Convert the resolver's authenticated record into the explicit settings + * boundary. The resolver authenticates the active grant; this function keeps + * the managed-record marker and relationship requirement visible at every + * record-settings route. + */ +export function resolveGuardianRecordSettingsAccess(context: { + actor: { id: string }; + grantId: string | null; + user: { id: string; managedProfileAt: Date | null }; +}): RecordSettingsAccess | null { + const access: RecordSettingsAccess = { + actorId: context.actor.id, + recordId: context.user.id, + recordKind: context.user.managedProfileAt ? "managed" : "self", + relationship: context.grantId ? "guardian" : "self", + }; + + try { + assertRecordSettingsAccess(access, "guardian"); + } catch { + return null; + } + + return access; +} diff --git a/src/lib/record-settings/dto.ts b/src/lib/record-settings/dto.ts new file mode 100644 index 000000000..678c49c7b --- /dev/null +++ b/src/lib/record-settings/dto.ts @@ -0,0 +1,32 @@ +export interface RecordSettingsDto { + record: { + id: string; + displayName: string; + locale: string | null; + timezone: string; + kind: "managed"; + }; +} + +/** + * The record-settings payload intentionally names the record it describes. + * It is never an extension of `/api/auth/me`, which remains the actor's + * identity payload while the browser is switched into another record. + */ +export function toRecordSettingsDto(source: { + id: string; + name: string | null; + locale: string | null; + timezone: string; + recordKind: "managed"; +}): RecordSettingsDto { + return { + record: { + id: source.id, + displayName: source.name ?? "", + locale: source.locale, + timezone: source.timezone, + kind: source.recordKind, + }, + }; +} diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts new file mode 100644 index 000000000..025afd665 --- /dev/null +++ b/src/lib/record-settings/index.ts @@ -0,0 +1,6 @@ +export { + assertRecordSettingsAccess, + resolveGuardianRecordSettingsAccess, + type RecordSettingsAccess, +} from "./access"; +export { toRecordSettingsDto, type RecordSettingsDto } from "./dto"; From d96507660913827db8f12a2ed25939ae3b83d75e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:27:27 +0200 Subject: [PATCH 03/12] test: cover settings classification - Require a literal settings inventory\n- Limit Guardian configuration to the approved subset --- .../record-settings-contract.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts index 98ea53cb0..b2267601f 100644 --- a/src/__tests__/record-settings-contract.test.ts +++ b/src/__tests__/record-settings-contract.test.ts @@ -4,6 +4,11 @@ import { assertRecordSettingsAccess, type RecordSettingsAccess, } from "@/lib/record-settings/access"; +import { + classifySettingsDestination, + isGuardianSettingsWriteAllowed, + SETTINGS_DESTINATION_INVENTORY, +} from "@/lib/record-settings/classification"; import { toRecordSettingsDto } from "@/lib/record-settings/dto"; import { recordSettingsKeys } from "@/lib/query-keys"; @@ -45,4 +50,20 @@ describe("record settings contract", () => { "Guardian configuration is unavailable for this record", ); }); + + it("classifies every Settings destination and limits Guardian writes", () => { + expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0); + expect(classifySettingsDestination("integrations")).toMatchObject({ + kind: "managed-guardian", + guardianWritable: false, + }); + expect(classifySettingsDestination("ai").kind).toBe("unavailable"); + expect(classifySettingsDestination("dashboard").kind).toBe( + "adult-shared-unavailable", + ); + expect(isGuardianSettingsWriteAllowed("account")).toBe(true); + expect(isGuardianSettingsWriteAllowed("notifications")).toBe(true); + expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false); + expect(isGuardianSettingsWriteAllowed("ai")).toBe(false); + }); }); From d95c48f48d82c0e3c018aaeb44f1ca363a80a453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:28:29 +0200 Subject: [PATCH 04/12] feat: classify managed record settings - Inventory every Settings destination as an explicit access class\n- Fail closed for unknown deep-link destinations --- src/lib/record-settings/classification.ts | 93 +++++++++++++++++++ src/lib/record-settings/index.ts | 7 ++ .../record-settings-authorization.test.ts | 16 ++++ 3 files changed, 116 insertions(+) create mode 100644 src/lib/record-settings/classification.ts diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts new file mode 100644 index 000000000..8c613c52f --- /dev/null +++ b/src/lib/record-settings/classification.ts @@ -0,0 +1,93 @@ +import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; + +export type SettingsDestinationKind = + | "personal" + | "managed-guardian" + | "adult-shared-unavailable" + | "unavailable"; + +export interface SettingsDestinationClassification { + kind: SettingsDestinationKind; + guardianWritable: boolean; +} + +const PERSONAL: SettingsDestinationClassification = { + kind: "personal", + guardianWritable: false, +}; + +const MANAGED_GUARDIAN: SettingsDestinationClassification = { + kind: "managed-guardian", + guardianWritable: true, +}; + +const MANAGED_GUARDIAN_STATUS: SettingsDestinationClassification = { + kind: "managed-guardian", + guardianWritable: false, +}; + +const ADULT_SHARED_UNAVAILABLE: SettingsDestinationClassification = { + kind: "adult-shared-unavailable", + guardianWritable: false, +}; + +const UNAVAILABLE: SettingsDestinationClassification = { + kind: "unavailable", + guardianWritable: false, +}; + +/** + * The complete Settings route inventory. `satisfies Record` makes adding a new destination a compile error until its record + * classification is reviewed. Unknown destinations resolve to unavailable so + * direct links fail closed rather than inheriting a neighbouring category. + */ +export const SETTINGS_DESTINATION_INVENTORY = { + account: MANAGED_GUARDIAN, + security: PERSONAL, + access: PERSONAL, + modules: MANAGED_GUARDIAN, + integrations: MANAGED_GUARDIAN_STATUS, + sources: UNAVAILABLE, + notifications: MANAGED_GUARDIAN, + layout: ADULT_SHARED_UNAVAILABLE, + dashboard: ADULT_SHARED_UNAVAILABLE, + insights: MANAGED_GUARDIAN, + medications: ADULT_SHARED_UNAVAILABLE, + mood: ADULT_SHARED_UNAVAILABLE, + labs: ADULT_SHARED_UNAVAILABLE, + illness: ADULT_SHARED_UNAVAILABLE, + environment: UNAVAILABLE, + anamnesis: ADULT_SHARED_UNAVAILABLE, + score: ADULT_SHARED_UNAVAILABLE, + vorsorge: ADULT_SHARED_UNAVAILABLE, + thresholds: MANAGED_GUARDIAN, + ai: UNAVAILABLE, + coach: MANAGED_GUARDIAN, + api: PERSONAL, + mcp: PERSONAL, + gesundheitsakte: PERSONAL, + sharing: UNAVAILABLE, + export: PERSONAL, + advanced: PERSONAL, + privacy: PERSONAL, + about: PERSONAL, +} as const satisfies Record; + +export function classifySettingsDestination( + destination: string, +): SettingsDestinationClassification { + return ( + SETTINGS_DESTINATION_INVENTORY[ + destination as SettingsSectionSlug + ] ?? UNAVAILABLE + ); +} + +export function isGuardianSettingsWriteAllowed(destination: string): boolean { + const classification = classifySettingsDestination(destination); + return ( + classification.kind === "managed-guardian" && + classification.guardianWritable + ); +} diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts index 025afd665..3212a6c57 100644 --- a/src/lib/record-settings/index.ts +++ b/src/lib/record-settings/index.ts @@ -4,3 +4,10 @@ export { type RecordSettingsAccess, } from "./access"; export { toRecordSettingsDto, type RecordSettingsDto } from "./dto"; +export { + classifySettingsDestination, + isGuardianSettingsWriteAllowed, + SETTINGS_DESTINATION_INVENTORY, + type SettingsDestinationClassification, + type SettingsDestinationKind, +} from "./classification"; diff --git a/tests/integration/record-settings-authorization.test.ts b/tests/integration/record-settings-authorization.test.ts index 15eaaf24c..838bcfc6b 100644 --- a/tests/integration/record-settings-authorization.test.ts +++ b/tests/integration/record-settings-authorization.test.ts @@ -4,6 +4,10 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings/access"; +import { + classifySettingsDestination, + SETTINGS_DESTINATION_INVENTORY, +} from "@/lib/record-settings/classification"; const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); @@ -32,4 +36,16 @@ describe("record settings authorization", () => { expect(recordSettingsRoute).toContain("requireGuardianAuth"); expect(recordSettingsRoute).toContain("toRecordSettingsDto"); }); + + it("keeps settings classification exhaustive and fail closed", () => { + expect(Object.keys(SETTINGS_DESTINATION_INVENTORY)).not.toHaveLength(0); + expect(classifySettingsDestination("integrations")).toMatchObject({ + kind: "managed-guardian", + guardianWritable: false, + }); + expect(classifySettingsDestination("ai").kind).toBe("unavailable"); + expect(classifySettingsDestination("unknown-settings-path").kind).toBe( + "unavailable", + ); + }); }); From 54cd3e39595251f211ef7bb7039d535bd158e3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:29:04 +0200 Subject: [PATCH 05/12] test: cover managed integration status - Require the record-scoped status query\n- Keep integration controls out of the managed view --- src/__tests__/record-settings-ui.test.tsx | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/__tests__/record-settings-ui.test.tsx diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx new file mode 100644 index 000000000..d650a027a --- /dev/null +++ b/src/__tests__/record-settings-ui.test.tsx @@ -0,0 +1,24 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); + +describe("managed record settings UI", () => { + it("renders integration status without connection controls", async () => { + const [statusView, settingsPage] = await Promise.all([ + readFile( + fromRoot("src/components/settings/managed-integration-status.tsx"), + "utf8", + ), + readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"), + ]); + + expect(statusView).toContain("recordSettingsIntegrations"); + expect(statusView).toContain("/api/record-settings/integrations"); + expect(statusView).not.toContain(" Date: Thu, 6 Aug 2026 02:38:54 +0200 Subject: [PATCH 06/12] feat: show managed integration status - Render a record-keyed status view for managed profiles\n- Refuse shared Settings links before actor controls mount\n- Localize intentional unavailable states --- messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/pl.json | 5 + .../record-settings-contract.test.ts | 30 +++++ src/__tests__/record-settings-ui.test.tsx | 31 +++-- .../api/record-settings/integrations/route.ts | 58 +++++++-- src/app/settings/[section]/page.tsx | 9 +- .../settings/managed-integration-status.tsx | 121 ++++++++++++++++++ .../settings/record-settings-section-gate.tsx | 50 ++++++++ src/lib/query-keys/record-settings.ts | 7 + src/lib/record-settings/access.ts | 5 +- src/lib/record-settings/classification.ts | 15 +-- src/lib/record-settings/index.ts | 1 + src/lib/record-settings/integrations.ts | 18 +++ 17 files changed, 343 insertions(+), 32 deletions(-) create mode 100644 src/components/settings/managed-integration-status.tsx create mode 100644 src/components/settings/record-settings-section-gate.tsx create mode 100644 src/lib/record-settings/integrations.ts diff --git a/messages/de.json b/messages/de.json index 388c99a38..faa9ce89e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4433,6 +4433,11 @@ "system": "System" } }, + "sharedRecord": { + "unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar", + "unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.", + "integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar." + }, "sections": { "account": { "title": "Konto", diff --git a/messages/en.json b/messages/en.json index 48f404d69..b40757598 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4433,6 +4433,11 @@ "system": "System" } }, + "sharedRecord": { + "unavailableTitle": "Unavailable in this shared record", + "unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.", + "integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable." + }, "sections": { "account": { "title": "Account", diff --git a/messages/es.json b/messages/es.json index bbd66c8fa..ca5332aed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4433,6 +4433,11 @@ "system": "Sistema" } }, + "sharedRecord": { + "unavailableTitle": "No disponible en este registro compartido", + "unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.", + "integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles." + }, "sections": { "account": { "title": "Cuenta", diff --git a/messages/fr.json b/messages/fr.json index 9f68a3ef5..34de11f03 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4433,6 +4433,11 @@ "system": "Système" } }, + "sharedRecord": { + "unavailableTitle": "Indisponible dans ce dossier partagé", + "unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.", + "integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles." + }, "sections": { "account": { "title": "Compte", diff --git a/messages/it.json b/messages/it.json index c37fdb228..3e2b9d38f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4433,6 +4433,11 @@ "system": "Sistema" } }, + "sharedRecord": { + "unavailableTitle": "Non disponibile in questo record condiviso", + "unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.", + "integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili." + }, "sections": { "account": { "title": "Account", diff --git a/messages/pl.json b/messages/pl.json index e97a5d6ea..ad45b25c4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4433,6 +4433,11 @@ "system": "System" } }, + "sharedRecord": { + "unavailableTitle": "Niedostępne w tym udostępnionym rekordzie", + "unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.", + "integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne." + }, "sections": { "account": { "title": "Konto", diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts index b2267601f..71395e9bb 100644 --- a/src/__tests__/record-settings-contract.test.ts +++ b/src/__tests__/record-settings-contract.test.ts @@ -10,6 +10,7 @@ import { SETTINGS_DESTINATION_INVENTORY, } from "@/lib/record-settings/classification"; import { toRecordSettingsDto } from "@/lib/record-settings/dto"; +import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations"; import { recordSettingsKeys } from "@/lib/query-keys"; describe("record settings contract", () => { @@ -36,6 +37,11 @@ describe("record settings contract", () => { "record-1", "detail", ]); + expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([ + "record-settings", + "record-1", + "integrations", + ]); }); it("does not let an adult MANAGE grant use Guardian-only configuration", () => { @@ -66,4 +72,28 @@ describe("record settings contract", () => { expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false); expect(isGuardianSettingsWriteAllowed("ai")).toBe(false); }); + + it("does not report a synthetic ledger row as a managed connection", () => { + const connected = { + withings: false, + whoop: false, + fitbit: false, + nightscout: false, + polar: false, + oura: false, + "google-health": false, + strava: false, + } as const; + + expect( + resolveManagedIntegrationState("connected", connected, "withings"), + ).toBe("disconnected"); + expect( + resolveManagedIntegrationState( + "error_reauth", + { ...connected, oura: true }, + "oura", + ), + ).toBe("error_reauth"); + }); }); diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index d650a027a..28521092d 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -6,19 +6,34 @@ import { describe, expect, it } from "vitest"; const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); describe("managed record settings UI", () => { - it("renders integration status without connection controls", async () => { - const [statusView, settingsPage] = await Promise.all([ - readFile( - fromRoot("src/components/settings/managed-integration-status.tsx"), - "utf8", - ), - readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"), - ]); + it("gates shared deep links and renders integration status without controls", async () => { + const [statusView, settingsPage, sectionGate, ...localeBundles] = + await Promise.all([ + readFile( + fromRoot("src/components/settings/managed-integration-status.tsx"), + "utf8", + ), + readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"), + readFile( + fromRoot("src/components/settings/record-settings-section-gate.tsx"), + "utf8", + ), + ...["de", "en", "es", "fr", "it", "pl"].map((locale) => + readFile(fromRoot(`messages/${locale}.json`), "utf8"), + ), + ]); expect(statusView).toContain("recordSettingsIntegrations"); expect(statusView).toContain("/api/record-settings/integrations"); expect(statusView).not.toContain(" { const context = await requireGuardianAuth(); const access = resolveGuardianRecordSettingsAccess(context); if (!access) throw new SharingAccessDeniedError(); - const integrations = await Promise.all( - MANAGED_INTEGRATIONS.map(async (integration) => { - const status = await getIntegrationStatus(access.recordId, integration); - return { - integration: status.integration, - state: status.state, - lastSuccessAt: status.lastSuccessAt, - lastAttemptAt: status.lastAttemptAt, - }; + const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([ + Promise.all( + MANAGED_INTEGRATIONS.map((integration) => + getIntegrationStatus(access.recordId, integration), + ), + ), + prisma.withingsConnection.findUnique({ + where: { userId: access.recordId }, + select: { id: true }, }), - ); + prisma.whoopConnection.findUnique({ + where: { userId: access.recordId }, + select: { whoopUserId: true }, + }), + prisma.fitbitConnection.findUnique({ + where: { userId: access.recordId }, + select: { id: true }, + }), + prisma.googleHealthConnection.findUnique({ + where: { userId: access.recordId }, + select: { id: true }, + }), + ]); + const connected: Record = { + withings: withings !== null, + whoop: whoop?.whoopUserId != null, + fitbit: fitbit !== null, + "google-health": googleHealth !== null, + nightscout: context.user.nightscoutUrlEncrypted !== null, + polar: context.user.polarAccessTokenEncrypted !== null, + oura: context.user.ouraAccessTokenEncrypted !== null, + strava: context.user.stravaAccessTokenEncrypted !== null, + }; + const integrations = statuses.map((status) => ({ + integration: status.integration, + state: resolveManagedIntegrationState( + status.state, + connected, + status.integration, + ), + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + })); annotate({ action: { name: "record-settings.integrations.get" } }); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 4df2a7c55..ee1f341af 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -39,6 +39,7 @@ import { type SettingsSectionSlug, } from "@/components/settings/section-slugs"; import { SettingsShell } from "@/components/settings/settings-shell"; +import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; /** * Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS` @@ -128,5 +129,11 @@ export default async function SettingsSectionPage({ params }: PageProps) { ); - return {body}; + return ( + + + {body} + + + ); } diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx new file mode 100644 index 000000000..28f3cd367 --- /dev/null +++ b/src/components/settings/managed-integration-status.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Plug } from "lucide-react"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type ManagedIntegrationState = + "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; + +interface ManagedIntegrationStatusResponse { + recordId: string; + integrations: Array<{ + integration: keyof typeof INTEGRATION_DISPLAY_NAMES; + state: ManagedIntegrationState; + lastSuccessAt: string | null; + lastAttemptAt: string | null; + }>; +} + +const INTEGRATION_DISPLAY_NAMES = { + withings: "Withings", + whoop: "WHOOP", + fitbit: "Fitbit", + nightscout: "Nightscout", + polar: "Polar", + oura: "Oura", + "google-health": "Google Health", + strava: "Strava", +} as const; + +function recordStatusPillState(state: ManagedIntegrationState) { + switch (state) { + case "connected": + return "connected" as const; + case "error_transient": + return "warning" as const; + case "error_reauth": + return "error" as const; + case "parked": + return "parked" as const; + case "disconnected": + return "disconnected" as const; + } +} + +/** + * The managed-record integration view is deliberately a status projection. + * It reads the active record's id from the resolved account-access payload and + * has no connection, credential, sync, or provider-control affordance. + */ +export function ManagedIntegrationStatus() { + const { t } = useTranslations(); + const { user } = useAuth(); + const activeRecord = user?.accountAccess?.active ?? null; + const recordId = activeRecord?.accountId ?? null; + const statusQuery = useQuery({ + queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""), + queryFn: () => + apiGet( + "/api/record-settings/integrations", + ), + enabled: activeRecord?.recordKind === "managed" && recordId !== null, + }); + + if (statusQuery.isError) { + return void statusQuery.refetch()} />; + } + + if (statusQuery.isLoading || !statusQuery.data) { + return ( + + + + ); + } + + return ( + + +
    + {statusQuery.data.integrations.map((integration) => ( +
  • + + {INTEGRATION_DISPLAY_NAMES[integration.integration]} + + +
  • + ))} +
+
+ ); +} diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx new file mode 100644 index 000000000..6d91aa71c --- /dev/null +++ b/src/components/settings/record-settings-section-gate.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { ReactNode } from "react"; +import { CircleSlash } from "lucide-react"; + +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useTranslations } from "@/lib/i18n/context"; +import { classifySettingsDestination } from "@/lib/record-settings"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status"; +import { SettingsCard } from "@/components/settings/settings-card"; +import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; + +/** + * Stops actor-scoped Settings components from mounting while the browser is + * switched into another record. The only managed integration surface is its + * status projection; all other direct links resolve to a local refusal state. + */ +export function RecordSettingsSectionGate({ + section, + children, +}: { + section: SettingsSectionSlug; + children: ReactNode; +}) { + const { inSharedRecord, recordKind } = useRecordCapabilities(); + const { t } = useTranslations(); + + if (!inSharedRecord) return children; + + const destination = classifySettingsDestination(section); + if ( + recordKind === "managed" && + destination.kind === "managed-guardian" && + section === "integrations" + ) { + return ; + } + + return ( + + + + ); +} diff --git a/src/lib/query-keys/record-settings.ts b/src/lib/query-keys/record-settings.ts index 3f47c26d3..7de37c03b 100644 --- a/src/lib/query-keys/record-settings.ts +++ b/src/lib/query-keys/record-settings.ts @@ -12,4 +12,11 @@ export const recordSettingsKeys = { ["record-settings", recordId, "integrations"] as const, profile: (recordId: string) => ["record-settings", recordId, "profile"] as const, + /** Named aliases stay unambiguous when assembled into `queryKeys`. */ + recordSettingsDetail: (recordId: string) => + ["record-settings", recordId, "detail"] as const, + recordSettingsIntegrations: (recordId: string) => + ["record-settings", recordId, "integrations"] as const, + recordSettingsProfile: (recordId: string) => + ["record-settings", recordId, "profile"] as const, }; diff --git a/src/lib/record-settings/access.ts b/src/lib/record-settings/access.ts index ba1f8c79b..024f50671 100644 --- a/src/lib/record-settings/access.ts +++ b/src/lib/record-settings/access.ts @@ -1,7 +1,4 @@ -export type RecordSettingsRelationship = - | "guardian" - | "self" - | "adult-manager"; +export type RecordSettingsRelationship = "guardian" | "self" | "adult-manager"; export interface RecordSettingsAccess { actorId: string; diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index 8c613c52f..9413fd128 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -1,10 +1,7 @@ import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; export type SettingsDestinationKind = - | "personal" - | "managed-guardian" - | "adult-shared-unavailable" - | "unavailable"; + "personal" | "managed-guardian" | "adult-shared-unavailable" | "unavailable"; export interface SettingsDestinationClassification { kind: SettingsDestinationKind; @@ -72,15 +69,17 @@ export const SETTINGS_DESTINATION_INVENTORY = { advanced: PERSONAL, privacy: PERSONAL, about: PERSONAL, -} as const satisfies Record; +} as const satisfies Record< + SettingsSectionSlug, + SettingsDestinationClassification +>; export function classifySettingsDestination( destination: string, ): SettingsDestinationClassification { return ( - SETTINGS_DESTINATION_INVENTORY[ - destination as SettingsSectionSlug - ] ?? UNAVAILABLE + SETTINGS_DESTINATION_INVENTORY[destination as SettingsSectionSlug] ?? + UNAVAILABLE ); } diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts index 3212a6c57..ba08366e8 100644 --- a/src/lib/record-settings/index.ts +++ b/src/lib/record-settings/index.ts @@ -4,6 +4,7 @@ export { type RecordSettingsAccess, } from "./access"; export { toRecordSettingsDto, type RecordSettingsDto } from "./dto"; +export { resolveManagedIntegrationState } from "./integrations"; export { classifySettingsDestination, isGuardianSettingsWriteAllowed, diff --git a/src/lib/record-settings/integrations.ts b/src/lib/record-settings/integrations.ts new file mode 100644 index 000000000..192dd7e7c --- /dev/null +++ b/src/lib/record-settings/integrations.ts @@ -0,0 +1,18 @@ +import type { + IntegrationKey, + IntegrationState, +} from "@/lib/integrations/status"; + +/** + * A status-ledger row alone cannot prove a connection: integrations without a + * row synthesize a healthy ledger state for the owner Settings dashboard. The + * managed projection must instead report those as disconnected until its + * record carries a real credential or connection row. + */ +export function resolveManagedIntegrationState( + state: IntegrationState, + connected: Record, + integration: IntegrationKey, +): IntegrationState { + return connected[integration] ? state : "disconnected"; +} From b639b064f993aa0dbe3056b19d72035571a20630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:42:01 +0200 Subject: [PATCH 07/12] test: register managed record settings routes - Freeze the guardian-only settings endpoints\n- Require an explicit purpose for both routes --- src/__tests__/sharing-surface-guard.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 2b86a2efe..0f9547c41 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1745,7 +1745,12 @@ const DELEGABLE_MANAGE_ROUTES: Record = { * the auth layer, or "no module reaches it" would be true because the symbol * had been renamed rather than because no module reaches it. */ -const GUARDIAN_ROUTES: Record = {}; +const GUARDIAN_ROUTES: Record = { + "app/api/record-settings/route.ts": + "The managed record's settings descriptor; it is refused for an adult record even with MANAGE.", + "app/api/record-settings/integrations/route.ts": + "Verified integration connection status for the managed record, without any provider control surface.", +}; /** * Route modules that deliberately resolve the ACTOR and keep working under a @@ -1784,9 +1789,10 @@ const ACTOR_ROUTES: Record = { * number rather than one per list on purpose: filling either of the new * literals collides here by construction, so two people filling them at once * find out from the merge rather than from production. Import-status polling - * adds two record entries and two MANAGE entries: 190 → 194. + * adds two record entries and two MANAGE entries: 190 → 194. Managed-record + * settings add two guardian-only entries: 194 → 196. */ -const FROZEN_ENTRY_COUNT = 194; +const FROZEN_ENTRY_COUNT = 196; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — From e788675b82141a2c04b3456f769f8e89788785e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 03:21:02 +0200 Subject: [PATCH 08/12] feat: configure managed record settings - Scope writable settings to the selected managed record\n- Render typed Guardian controls with separated audit attribution --- .../record-settings-configuration.test.ts | 98 +++ .../record-settings-contract.test.ts | 16 + .../record-settings-route-guard.test.ts | 61 ++ src/__tests__/record-settings-ui.test.tsx | 54 +- src/__tests__/sharing-surface-guard.test.ts | 7 +- src/app/api/record-settings/[family]/route.ts | 378 +++++++++ src/app/settings/layout/[module]/page.tsx | 13 +- .../auth-shell-shared-record-doors.test.tsx | 17 + src/components/layout/auth-shell.tsx | 13 +- .../managed-record-settings-section.test.tsx | 65 ++ .../record-settings-section-gate.test.tsx | 80 ++ .../settings/managed-integration-status.tsx | 10 +- .../managed-record-settings-section.tsx | 801 ++++++++++++++++++ .../settings/record-settings-section-gate.tsx | 38 +- src/lib/query-keys/record-settings.ts | 4 + src/lib/record-settings/configuration.ts | 198 +++++ src/lib/record-settings/index.ts | 10 + src/lib/record-settings/response.ts | 14 + .../record-settings-authorization.test.ts | 31 + 19 files changed, 1879 insertions(+), 29 deletions(-) create mode 100644 src/__tests__/record-settings-configuration.test.ts create mode 100644 src/__tests__/record-settings-route-guard.test.ts create mode 100644 src/app/api/record-settings/[family]/route.ts create mode 100644 src/components/settings/__tests__/managed-record-settings-section.test.tsx create mode 100644 src/components/settings/__tests__/record-settings-section-gate.test.tsx create mode 100644 src/components/settings/managed-record-settings-section.tsx create mode 100644 src/lib/record-settings/configuration.ts create mode 100644 src/lib/record-settings/response.ts diff --git a/src/__tests__/record-settings-configuration.test.ts b/src/__tests__/record-settings-configuration.test.ts new file mode 100644 index 000000000..27c078dc4 --- /dev/null +++ b/src/__tests__/record-settings-configuration.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { + MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST, + parseManagedRecordSettingsPatch, +} from "@/lib/record-settings/configuration"; + +describe("managed record settings configuration contract", () => { + it("enumerates the only target-record fields each settings DTO may write", () => { + expect(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST).toEqual({ + profile: [ + "displayName", + "heightCm", + "dateOfBirth", + "gender", + "locale", + "timezone", + "unitPreference", + "timeFormat", + "dateFormat", + ], + modules: ["modulePreferences"], + notifications: ["moodReminderEnabled", "notificationPreferences"], + thresholds: ["overrides"], + coach: ["disableCoach", "preferences"], + insights: ["layout"], + }); + }); + + it.each([ + ["profile", { email: "not-allowed@example.test" }], + ["modules", { role: "ADMIN" }], + [ + "notifications", + { + notificationPreferences: { medication: { deliveryDefault: "client" } }, + }, + ], + ["thresholds", { glucoseUnit: "imperial" }], + ["coach", { memories: [] }], + ["insights", { provider: "external" }], + ] as const)("rejects a disallowed %s field", (family, patch) => { + expect(() => parseManagedRecordSettingsPatch(family, patch)).toThrow(); + }); + + it.each([ + [ + "profile", + { + displayName: "Managed profile", + heightCm: 120, + dateOfBirth: "2016-04-03", + gender: "OTHER", + locale: "en", + timezone: "Europe/Berlin", + unitPreference: "metric", + timeFormat: "H24", + dateFormat: "DMY", + }, + ], + ["modules", { modulePreferences: { mood: false } }], + [ + "notifications", + { + moodReminderEnabled: true, + notificationPreferences: { + medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 }, + mood: { reminderHour: 22 }, + }, + }, + ], + ["thresholds", { overrides: { WEIGHT: { min: 55, max: 80 } } }], + [ + "coach", + { + disableCoach: false, + preferences: { + tone: "warm", + verbosity: "default", + excludeMetrics: [], + showEvidenceByDefault: false, + defaultWindow: "allTime", + }, + }, + ], + [ + "insights", + { + layout: { + version: 2, + tiles: [{ id: "weight", visible: true, order: 0 }], + }, + }, + ], + ] as const)("accepts a typed %s patch", (family, patch) => { + expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow(); + }); +}); diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts index 71395e9bb..5173a575e 100644 --- a/src/__tests__/record-settings-contract.test.ts +++ b/src/__tests__/record-settings-contract.test.ts @@ -11,6 +11,7 @@ import { } from "@/lib/record-settings/classification"; import { toRecordSettingsDto } from "@/lib/record-settings/dto"; import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations"; +import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response"; import { recordSettingsKeys } from "@/lib/query-keys"; describe("record settings contract", () => { @@ -96,4 +97,19 @@ describe("record settings contract", () => { ), ).toBe("error_reauth"); }); + + it("rejects a late response for another record in a second tab", () => { + expect(() => + assertRecordSettingsResponseForRecord( + { recordId: "managed-record-b" }, + "managed-record-a", + ), + ).toThrow("did not match its key"); + expect(() => + assertRecordSettingsResponseForRecord( + { recordId: "managed-record-a" }, + "managed-record-a", + ), + ).not.toThrow(); + }); }); diff --git a/src/__tests__/record-settings-route-guard.test.ts b/src/__tests__/record-settings-route-guard.test.ts new file mode 100644 index 000000000..9d398a045 --- /dev/null +++ b/src/__tests__/record-settings-route-guard.test.ts @@ -0,0 +1,61 @@ +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST } from "@/lib/record-settings"; + +const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); + +const RECORD_SETTINGS_ROUTE_ROOT = fromRoot("src/app/api/record-settings"); + +describe("record settings route guard", () => { + it("freezes the non-empty guardian-only route inventory", async () => { + const discovered = ( + await readdir(RECORD_SETTINGS_ROUTE_ROOT, { + recursive: true, + }) + ) + .filter((entry) => entry.endsWith("route.ts")) + .map((entry) => `app/api/record-settings/${entry}`) + .sort(); + + expect(discovered).toHaveLength(3); + expect(discovered).toEqual([ + "app/api/record-settings/[family]/route.ts", + "app/api/record-settings/integrations/route.ts", + "app/api/record-settings/route.ts", + ]); + }); + + it("resolves every record-settings route through the Guardian fence", async () => { + const routes = [ + "route.ts", + "integrations/route.ts", + "[family]/route.ts", + ] as const; + const sources = await Promise.all( + routes.map((route) => + readFile(resolve(RECORD_SETTINGS_ROUTE_ROOT, route), "utf8"), + ), + ); + + for (const source of sources) { + expect(source).toContain("requireGuardianAuth"); + expect(source).toContain("resolveGuardianRecordSettingsAccess"); + } + }); + + it("makes the field-specific route exhaustive over the approved DTO families", async () => { + const source = await readFile( + resolve(RECORD_SETTINGS_ROUTE_ROOT, "[family]/route.ts"), + "utf8", + ); + + const families = Object.keys(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST); + expect(families.length).toBeGreaterThan(0); + for (const family of families) { + expect(source).toContain(`case \"${family}\"`); + } + }); +}); diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index 28521092d..92b6fd56e 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -7,29 +7,53 @@ const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); describe("managed record settings UI", () => { it("gates shared deep links and renders integration status without controls", async () => { - const [statusView, settingsPage, sectionGate, ...localeBundles] = - await Promise.all([ - readFile( - fromRoot("src/components/settings/managed-integration-status.tsx"), - "utf8", - ), - readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"), - readFile( - fromRoot("src/components/settings/record-settings-section-gate.tsx"), - "utf8", - ), - ...["de", "en", "es", "fr", "it", "pl"].map((locale) => - readFile(fromRoot(`messages/${locale}.json`), "utf8"), - ), - ]); + const [ + statusView, + managedSettingsView, + settingsPage, + layoutModulePage, + sectionGate, + ...localeBundles + ] = await Promise.all([ + readFile( + fromRoot("src/components/settings/managed-integration-status.tsx"), + "utf8", + ), + readFile( + fromRoot("src/components/settings/managed-record-settings-section.tsx"), + "utf8", + ), + readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"), + readFile(fromRoot("src/app/settings/layout/[module]/page.tsx"), "utf8"), + readFile( + fromRoot("src/components/settings/record-settings-section-gate.tsx"), + "utf8", + ), + ...["de", "en", "es", "fr", "it", "pl"].map((locale) => + readFile(fromRoot(`messages/${locale}.json`), "utf8"), + ), + ]); expect(statusView).toContain("recordSettingsIntegrations"); expect(statusView).toContain("/api/record-settings/integrations"); + expect(statusView).toContain("assertRecordSettingsResponseForRecord"); expect(statusView).not.toContain(" = { "The managed record's settings descriptor; it is refused for an adult record even with MANAGE.", "app/api/record-settings/integrations/route.ts": "Verified integration connection status for the managed record, without any provider control surface.", + "app/api/record-settings/[family]/route.ts": + "The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.", }; /** @@ -1790,9 +1792,10 @@ const ACTOR_ROUTES: Record = { * literals collides here by construction, so two people filling them at once * find out from the merge rather than from production. Import-status polling * adds two record entries and two MANAGE entries: 190 → 194. Managed-record - * settings add two guardian-only entries: 194 → 196. + * settings add two guardian-only entries: 194 → 196. Field-specific + * managed-record configuration adds one more: 196 → 197. */ -const FROZEN_ENTRY_COUNT = 196; +const FROZEN_ENTRY_COUNT = 197; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts new file mode 100644 index 000000000..56f2f82ff --- /dev/null +++ b/src/app/api/record-settings/[family]/route.ts @@ -0,0 +1,378 @@ +import type { NextRequest } from "next/server"; + +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { + invalidateUserInsightsLayout, + invalidateUserProfile, +} from "@/lib/cache/invalidate"; +import { prisma, toJson } from "@/lib/db"; +import { type ThresholdOverridesJson } from "@/lib/analytics/effective-range"; +import { + isManagedRecordSettingsFamily, + managedModulePreferencesFrom, + parseManagedRecordSettingsPatch, + resolveGuardianRecordSettingsAccess, +} from "@/lib/record-settings"; +import { + resolveInsightsLayout, + serializeInsightsLayout, +} from "@/lib/insights-layout"; +import { annotate } from "@/lib/logging/context"; +import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; +import { + parseNotificationPrefs, + resolveNotificationPrefs, +} from "@/lib/validations/notification-prefs"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ family: string }> }; + +async function resolveManagedRecordSettings(params: RouteParams["params"]) { + const { family: rawFamily } = await params; + if (!isManagedRecordSettingsFamily(rawFamily)) return null; + + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + + return { access, family: rawFamily }; +} + +/** Each response names the target record and only one field-specific DTO. */ +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const resolved = await resolveManagedRecordSettings(params); + if (!resolved) return apiError("Settings family not found", 404); + const { access, family } = resolved; + + const record = await prisma.user.findUnique({ + where: { id: access.recordId }, + select: { + displayName: true, + heightCm: true, + dateOfBirth: true, + gender: true, + locale: true, + timezone: true, + unitPreference: true, + timeFormat: true, + dateFormat: true, + modulePreferencesJson: true, + moodReminderEnabled: true, + notificationPrefs: true, + thresholdsJson: true, + disableCoach: true, + coachPrefsJson: true, + insightsLayoutJson: true, + }, + }); + if (!record) return apiError("Record not found", 404); + + let settings: unknown; + switch (family) { + case "profile": + settings = { + displayName: record.displayName, + heightCm: record.heightCm, + dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null, + gender: record.gender, + locale: record.locale, + timezone: record.timezone, + unitPreference: record.unitPreference, + timeFormat: record.timeFormat, + dateFormat: record.dateFormat, + }; + break; + case "modules": + settings = { + modulePreferences: managedModulePreferencesFrom( + record.modulePreferencesJson, + ), + }; + break; + case "notifications": { + const preferences = parseNotificationPrefs(record.notificationPrefs); + settings = { + moodReminderEnabled: record.moodReminderEnabled, + notificationPreferences: { + medication: { + lowStockRunwayDays: preferences.medication.lowStockRunwayDays, + reorderLeadDays: preferences.medication.reorderLeadDays, + }, + mood: { reminderHour: preferences.mood.reminderHour }, + }, + }; + break; + } + case "thresholds": + settings = { + overrides: (record.thresholdsJson ?? {}) as ThresholdOverridesJson, + }; + break; + case "coach": { + const preferences = parseCoachPrefs(record.coachPrefsJson); + settings = { + disableCoach: record.disableCoach, + preferences: { + tone: preferences.tone, + verbosity: preferences.verbosity, + excludeMetrics: preferences.excludeMetrics, + showEvidenceByDefault: preferences.showEvidenceByDefault, + defaultWindow: preferences.defaultWindow, + ...(preferences.dataClusters !== undefined + ? { dataClusters: preferences.dataClusters } + : {}), + }, + }; + break; + } + case "insights": + settings = { layout: resolveInsightsLayout(record.insightsLayoutJson) }; + break; + } + + annotate({ action: { name: `record-settings.${family}.get` } }); + return apiSuccess({ recordId: access.recordId, family, settings }); + }, +); + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const resolved = await resolveManagedRecordSettings(params); + if (!resolved) return apiError("Settings family not found", 404); + const { access, family } = resolved; + const { data: body, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (error) return error; + + try { + let settings: unknown; + let changed: readonly string[]; + + switch (family) { + case "profile": { + const patch = parseManagedRecordSettingsPatch("profile", body); + const data = { + ...(patch.displayName !== undefined + ? { displayName: patch.displayName } + : {}), + ...(patch.heightCm !== undefined + ? { heightCm: patch.heightCm } + : {}), + ...(patch.dateOfBirth !== undefined + ? { + dateOfBirth: patch.dateOfBirth + ? new Date(`${patch.dateOfBirth}T00:00:00.000Z`) + : null, + } + : {}), + ...(patch.gender !== undefined ? { gender: patch.gender } : {}), + ...(patch.locale !== undefined ? { locale: patch.locale } : {}), + ...(patch.timezone !== undefined + ? { timezone: patch.timezone } + : {}), + ...(patch.unitPreference !== undefined + ? { unitPreference: patch.unitPreference } + : {}), + ...(patch.timeFormat !== undefined + ? { timeFormat: patch.timeFormat } + : {}), + ...(patch.dateFormat !== undefined + ? { dateFormat: patch.dateFormat } + : {}), + }; + const record = await prisma.user.update({ + where: { id: access.recordId }, + data, + select: { + displayName: true, + heightCm: true, + dateOfBirth: true, + gender: true, + locale: true, + timezone: true, + unitPreference: true, + timeFormat: true, + dateFormat: true, + }, + }); + invalidateUserProfile(access.recordId); + settings = { + ...record, + dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null, + }; + changed = Object.keys(patch); + break; + } + case "modules": { + const patch = parseManagedRecordSettingsPatch("modules", body); + const current = await prisma.user.findUnique({ + where: { id: access.recordId }, + select: { modulePreferencesJson: true }, + }); + const modulePreferences = { + ...managedModulePreferencesFrom(current?.modulePreferencesJson), + ...patch.modulePreferences, + }; + await prisma.user.update({ + where: { id: access.recordId }, + data: { modulePreferencesJson: toJson(modulePreferences) }, + }); + settings = { modulePreferences }; + changed = Object.keys(patch.modulePreferences); + break; + } + case "notifications": { + const patch = parseManagedRecordSettingsPatch("notifications", body); + const current = await prisma.user.findUnique({ + where: { id: access.recordId }, + select: { notificationPrefs: true }, + }); + const notificationPreferences = patch.notificationPreferences + ? resolveNotificationPrefs( + current?.notificationPrefs, + patch.notificationPreferences, + ) + : undefined; + const record = await prisma.user.update({ + where: { id: access.recordId }, + data: { + ...(patch.moodReminderEnabled !== undefined + ? { moodReminderEnabled: patch.moodReminderEnabled } + : {}), + ...(notificationPreferences !== undefined + ? { notificationPrefs: toJson(notificationPreferences) } + : {}), + }, + select: { moodReminderEnabled: true, notificationPrefs: true }, + }); + const preferences = parseNotificationPrefs(record.notificationPrefs); + settings = { + moodReminderEnabled: record.moodReminderEnabled, + notificationPreferences: { + medication: { + lowStockRunwayDays: preferences.medication.lowStockRunwayDays, + reorderLeadDays: preferences.medication.reorderLeadDays, + }, + mood: { reminderHour: preferences.mood.reminderHour }, + }, + }; + changed = Object.keys(patch); + break; + } + case "thresholds": { + const patch = parseManagedRecordSettingsPatch("thresholds", body); + const current = await prisma.user.findUnique({ + where: { id: access.recordId }, + select: { thresholdsJson: true }, + }); + const overrides = { + ...((current?.thresholdsJson ?? {}) as ThresholdOverridesJson), + ...patch.overrides, + }; + await prisma.user.update({ + where: { id: access.recordId }, + data: { thresholdsJson: toJson(overrides) }, + }); + settings = { overrides }; + changed = Object.keys(patch.overrides); + break; + } + case "coach": { + const patch = parseManagedRecordSettingsPatch("coach", body); + const current = await prisma.user.findUnique({ + where: { id: access.recordId }, + select: { coachPrefsJson: true }, + }); + const nextPreferences = patch.preferences + ? { + ...parseCoachPrefs(current?.coachPrefsJson), + ...patch.preferences, + } + : undefined; + const record = await prisma.user.update({ + where: { id: access.recordId }, + data: { + ...(patch.disableCoach !== undefined + ? { disableCoach: patch.disableCoach } + : {}), + ...(nextPreferences !== undefined + ? { + coachPrefsJson: toJson(nextPreferences), + insightsExcludeMetrics: nextPreferences.excludeMetrics, + } + : {}), + }, + select: { disableCoach: true, coachPrefsJson: true }, + }); + const preferences = parseCoachPrefs(record.coachPrefsJson); + settings = { + disableCoach: record.disableCoach, + preferences: { + tone: preferences.tone, + verbosity: preferences.verbosity, + excludeMetrics: preferences.excludeMetrics, + showEvidenceByDefault: preferences.showEvidenceByDefault, + defaultWindow: preferences.defaultWindow, + ...(preferences.dataClusters !== undefined + ? { dataClusters: preferences.dataClusters } + : {}), + }, + }; + changed = Object.keys(patch); + break; + } + case "insights": { + const patch = parseManagedRecordSettingsPatch("insights", body); + const current = await prisma.user.findUnique({ + where: { id: access.recordId }, + select: { insightsLayoutJson: true }, + }); + const layout = serializeInsightsLayout( + patch.layout, + resolveInsightsLayout(current?.insightsLayoutJson), + ); + await prisma.user.update({ + where: { id: access.recordId }, + data: { insightsLayoutJson: toJson(layout) }, + }); + invalidateUserInsightsLayout(access.recordId); + settings = { layout }; + changed = ["layout"]; + break; + } + } + + await auditLog(`record-settings.${family}.update`, { + userId: access.recordId, + actorUserId: access.actorId, + ipAddress: getClientIp(request), + details: { changed }, + }); + annotate({ + action: { name: `record-settings.${family}.update` }, + meta: { recordId: access.recordId, changed }, + }); + return apiSuccess({ recordId: access.recordId, family, settings }); + } catch (error) { + if (error instanceof Error && "issues" in error) { + return returnAllZodIssues(error as never, 422); + } + throw error; + } + }, +); diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx index ef0f6ad9a..663f756e5 100644 --- a/src/app/settings/layout/[module]/page.tsx +++ b/src/app/settings/layout/[module]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import { LayoutModuleGate } from "@/components/settings/layout-module-gate"; +import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; import { LAYOUT_GROUPS, LAYOUT_GROUP_IDS, @@ -68,11 +69,13 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) { headingId, }} > -
- - - -
+ +
+ + + +
+
); } diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx index 7e05b31b5..93d4c7d01 100644 --- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx +++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx @@ -126,6 +126,16 @@ const OWNER_WRITE: AccountAccessEntry = { level: "write", canWrite: true, }; +const MANAGED_GUARDIAN: AccountAccessEntry = { + accountId: "managed-record", + username: "managed-record", + displayName: "Managed profile", + access: "write" as const, + level: "manage" as const, + sections: null, + recordKind: "managed" as const, + canWrite: true, +}; function render(access: AccountAccess, pathname = "/"): string { mockAccessRef.value = access; @@ -199,4 +209,11 @@ describe(" — the owner-only doors", () => { expect(html).not.toContain(slot); } }); + + it("admits a switched Guardian to the record-aware Settings gate", () => { + const html = render(SWITCHED(MANAGED_GUARDIAN), "/settings/integrations"); + + expect(html).toContain("sentinel-page"); + expect(html).not.toContain("sentinel-unavailable"); + }); }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index a16a22a86..ae67180d7 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -78,14 +78,21 @@ export function AuthShell({ // decides what to mount and the surfaces beneath it decide what to offer, // and the two disagreeing is the whole class of defect this hook exists to // close. - const { inSharedRecord } = useRecordCapabilities(); + const { inSharedRecord, recordKind } = useRecordCapabilities(); + // A managed profile has a deliberately small Settings surface of its own. + // Let that route family reach its record-aware gate; adult shared records + // stay in the generic refusal branch even at MANAGE. + const isManagedRecordSettingsPath = + recordKind === "managed" && pathname.startsWith("/settings"); // v1.36.0 — a page reached while acting on somebody else's record that // sharing does not cover. The nav already drops these entries, so arriving // here means a bookmark or a browser back button; the panel below explains // it instead of letting the page paint a row of 403 error cards. Paint only // — every route behind these pages refuses on its own. const outsideSharedRecord = - inSharedRecord && !isDestinationInSharedRecord(pathname); + inSharedRecord && + !isDestinationInSharedRecord(pathname) && + !isManagedRecordSettingsPath; const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; @@ -222,7 +229,7 @@ export function AuthShell({ // flight: the role is unknown until the payload lands, and mounting // `/admin/*` children early would fire admin queries that 403 for a // non-admin before the redirect effect can move them away. - if (isLoading && isAdminPage) { + if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) { return (
diff --git a/src/components/settings/__tests__/managed-record-settings-section.test.tsx b/src/components/settings/__tests__/managed-record-settings-section.test.tsx new file mode 100644 index 000000000..050e3c1dc --- /dev/null +++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ManagedRecordSettingsForm } from "../managed-record-settings-section"; + +const render = ( + family: Parameters[0]["family"], + settings: Record, +) => + renderToStaticMarkup( + , + ); + +describe("managed record settings forms", () => { + it.each([ + ["profile", { timezone: "Europe/Berlin" }, "managed-display-name"], + ["modules", { modulePreferences: { mood: true } }, "mood"], + [ + "notifications", + { + moodReminderEnabled: true, + notificationPreferences: { + medication: { lowStockRunwayDays: 7, reorderLeadDays: 10 }, + mood: { reminderHour: 22 }, + }, + }, + "managed-reminder-hour", + ], + [ + "thresholds", + { overrides: { WEIGHT: { min: 55, max: 80 } } }, + "managed-threshold-metric", + ], + [ + "coach", + { disableCoach: false, preferences: { tone: "warm" } }, + "managed-coach-tone", + ], + [ + "insights", + { + layout: { + version: 2, + tiles: [{ id: "weight", visible: true, order: 0 }], + }, + }, + "tile-order-weight", + ], + ] as const)( + "renders labeled %s controls without an internal JSON editor", + (family, settings, control) => { + const html = render(family, settings); + + expect(html).toContain(control); + expect(html).toContain("Save"); + expect(html).not.toContain(" ({ + useAuth: () => ({ + isLoading: authRef.isLoading, + user: authRef.active + ? { + accountAccess: { + active: authRef.active, + accounts: [authRef.active], + canSwitch: true, + }, + } + : null, + }), +})); + +vi.mock("../managed-record-settings-section", () => ({ + ManagedRecordSettingsSection: () =>
, +})); +vi.mock("../managed-integration-status", () => ({ + ManagedIntegrationStatus: () =>
, +})); + +import { RecordSettingsSectionGate } from "../record-settings-section-gate"; + +function render(section: "modules" | "integrations") { + return renderToStaticMarkup( + + +
+ + , + ); +} + +describe("RecordSettingsSectionGate", () => { + it("does not mount an actor Settings component while auth is unresolved", () => { + authRef.isLoading = true; + authRef.active = null; + + const html = render("modules"); + + expect(html).not.toContain("actor-settings"); + expect(html).toContain('role="status"'); + }); + + it("uses the record-scoped section under a switched Guardian", () => { + authRef.isLoading = false; + authRef.active = { + accountId: "managed-record", + recordKind: "managed", + level: "manage", + sections: null, + canWrite: true, + }; + + const html = render("modules"); + + expect(html).toContain("managed-settings"); + expect(html).not.toContain("actor-settings"); + }); +}); diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index 28f3cd367..fe94431e5 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -11,6 +11,7 @@ import { apiGet } from "@/lib/api/api-fetch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings"; type ManagedIntegrationState = "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; @@ -63,10 +64,13 @@ export function ManagedIntegrationStatus() { const recordId = activeRecord?.accountId ?? null; const statusQuery = useQuery({ queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""), - queryFn: () => - apiGet( + queryFn: async () => { + const response = await apiGet( "/api/record-settings/integrations", - ), + ); + assertRecordSettingsResponseForRecord(response, recordId); + return response; + }, enabled: activeRecord?.recordKind === "managed" && recordId !== null, }); diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx new file mode 100644 index 000000000..ae9fc3608 --- /dev/null +++ b/src/components/settings/managed-record-settings-section.tsx @@ -0,0 +1,801 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Settings } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { + assertRecordSettingsResponseForRecord, + safeParseManagedRecordSettingsPatch, + type ManagedRecordSettingsFamily, +} from "@/lib/record-settings"; +import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { SettingsCardHeader } from "./_card-header"; +import { SettingsCard } from "./settings-card"; + +interface ManagedRecordSettingsResponse { + recordId: string; + family: ManagedRecordSettingsFamily; + settings: Record; +} + +type ManagedSettingsFamily = Exclude< + ManagedRecordSettingsFamily, + "integrations" +>; +type SettingsFormProps = { + settings: Record; + disabled: boolean; + onSave: (patch: unknown) => void; + saveLabel: string; +}; + +const COACH_EXCLUDE_METRICS = [ + "bp", + "weight", + "pulse", + "mood", + "compliance", + "hrv", + "sleep", + "resting_hr", + "steps", + "medications", + "anthropometrics", +] as const; +const COACH_DATA_CLUSTERS = [ + "cardio", + "body", + "activity", + "workouts", + "sleep", + "mood", + "glucose", + "medication", + "mobility", + "environment", +] as const; + +function asRecord(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function formNumber(form: FormData, name: string, fallback: number): number { + const value = Number(form.get(name)); + return Number.isFinite(value) ? value : fallback; +} + +function SaveButton({ disabled, label }: { disabled: boolean; label: string }) { + return ( + + ); +} + +function ProfileSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const displayName = asString(form.get("displayName")).trim(); + const heightValue = asString(form.get("heightCm")).trim(); + onSave({ + displayName: displayName || null, + heightCm: heightValue === "" ? null : Number(heightValue), + dateOfBirth: asString(form.get("dateOfBirth")) || null, + gender: asString(form.get("gender")) || null, + locale: asString(form.get("locale")) || null, + timezone: asString(form.get("timezone")), + unitPreference: asString(form.get("unitPreference")), + timeFormat: asString(form.get("timeFormat")), + dateFormat: asString(form.get("dateFormat")), + }); + } + + return ( +
+ + + + + + + + + +
+ +
+
+ ); +} + +function ModulesSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const preferences = asRecord(settings.modulePreferences); + const entries = Object.entries(preferences).sort(([left], [right]) => + left.localeCompare(right), + ); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + modulePreferences: Object.fromEntries( + entries.map(([key]) => [key, form.get(key) === "on"]), + ), + }); + } + + return ( +
+ {entries.map(([key, enabled]) => ( + + ))} + + + ); +} + +function NotificationsSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const preferences = asRecord(settings.notificationPreferences); + const medication = asRecord(preferences.medication); + const mood = asRecord(preferences.mood); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + moodReminderEnabled: form.get("moodReminderEnabled") === "on", + notificationPreferences: { + medication: { + lowStockRunwayDays: + form.get("lowStockRunwayDays") === "" + ? null + : formNumber(form, "lowStockRunwayDays", 7), + reorderLeadDays: formNumber(form, "reorderLeadDays", 10), + }, + mood: { reminderHour: formNumber(form, "reminderHour", 22) }, + }, + }); + } + + return ( +
+ + + + +
+ +
+
+ ); +} + +function ThresholdsSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const overrides = asRecord(settings.overrides); + const metrics = Object.keys(overrides); + const metric = metrics[0] ?? "WEIGHT"; + const firstRange = asRecord(overrides[metric]); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const selectedMetric = asString(form.get("metric"), metric); + onSave({ + overrides: { + [selectedMetric]: { + min: formNumber(form, "minimum", asNumber(firstRange.min, 0)), + max: formNumber(form, "maximum", asNumber(firstRange.max, 0)), + }, + }, + }); + } + + return ( +
+ + + +
+ +
+
+ ); +} + +function CoachSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const preferences = asRecord(settings.preferences); + const excluded = new Set(asStringArray(preferences.excludeMetrics)); + const dataClusters = new Set(asStringArray(preferences.dataClusters)); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + disableCoach: form.get("disableCoach") === "on", + preferences: { + tone: asString(form.get("tone"), "warm"), + verbosity: asString(form.get("verbosity"), "default"), + excludeMetrics: COACH_EXCLUDE_METRICS.filter( + (metric) => form.get(`exclude-${metric}`) === "on", + ), + showEvidenceByDefault: form.get("showEvidenceByDefault") === "on", + defaultWindow: asString(form.get("defaultWindow"), "allTime"), + ...(form.get("useCustomClusters") === "on" + ? { + dataClusters: COACH_DATA_CLUSTERS.filter( + (cluster) => form.get(`cluster-${cluster}`) === "on", + ), + } + : {}), + }, + }); + } + + return ( +
+ +
+ + + +
+ +
+ Excluded metrics + {COACH_EXCLUDE_METRICS.map((metric) => ( + + ))} +
+
+ Data clusters + + {COACH_DATA_CLUSTERS.map((cluster) => ( + + ))} +
+ + + ); +} + +type LayoutItem = { id: string; visible: boolean; order: number }; + +function layoutItems(value: unknown): LayoutItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const item = asRecord(entry); + const id = asString(item.id); + return id + ? [ + { + id, + visible: item.visible === true, + order: asNumber(item.order, 0), + }, + ] + : []; + }); +} + +function InsightsSettingsForm({ + settings, + disabled, + onSave, + saveLabel, +}: SettingsFormProps) { + const layout = asRecord(settings.layout); + const sections = layoutItems(layout.sections); + const tiles = layoutItems(layout.tiles); + + function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const toPatch = (kind: "section" | "tile", item: LayoutItem) => ({ + id: item.id, + visible: form.get(`${kind}-visible-${item.id}`) === "on", + order: formNumber(form, `${kind}-order-${item.id}`, item.order), + }); + onSave({ + layout: { + version: asNumber(layout.version, 2), + sections: sections.map((section) => toPatch("section", section)), + tiles: tiles.map((tile) => toPatch("tile", tile)), + }, + }); + } + + const renderItems = (kind: "section" | "tile", items: LayoutItem[]) => ( +
+ + {kind === "section" ? "Overview sections" : "Navigation tiles"} + + {items.map((item) => ( +
+ + +
+ ))} +
+ ); + + return ( +
+ {renderItems("section", sections)} + {renderItems("tile", tiles)} + + + ); +} + +export function ManagedRecordSettingsForm({ + family, + ...props +}: SettingsFormProps & { family: ManagedSettingsFamily }) { + switch (family) { + case "profile": + return ; + case "modules": + return ; + case "notifications": + return ; + case "thresholds": + return ; + case "coach": + return ; + case "insights": + return ; + } +} + +/** + * Managed settings use typed target-record controls only. The actor's + * Settings components never mount here, so their actor-scoped queries and + * mutations cannot fire while a Guardian administers a profile. + */ +export function ManagedRecordSettingsSection({ + family, +}: { + family: ManagedSettingsFamily; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const recordId = user?.accountAccess?.active?.accountId ?? null; + const [validationFailed, setValidationFailed] = useState(false); + const query = useQuery({ + queryKey: queryKeys.recordSettingsFamily(recordId ?? "", family), + enabled: recordId !== null, + queryFn: async () => { + const response = await apiGet( + `/api/record-settings/${family}`, + ); + assertRecordSettingsResponseForRecord(response, recordId); + if (response.family !== family) { + throw new Error( + "Managed record settings response did not match its key", + ); + } + return response; + }, + }); + const save = useMutation({ + mutationFn: async (patch: unknown) => { + const response = await apiPatch( + `/api/record-settings/${family}`, + patch, + ); + assertRecordSettingsResponseForRecord(response, recordId); + if (response.family !== family) { + throw new Error( + "Managed record settings response did not match its key", + ); + } + return response; + }, + onSuccess: (response) => { + if (recordId === null) return; + queryClient.setQueryData( + queryKeys.recordSettingsFamily(recordId, family), + response, + ); + }, + }); + + if (query.isError) { + return void query.refetch()} />; + } + + return ( + + + {query.data ? ( + <> + { + const parsed = safeParseManagedRecordSettingsPatch(family, patch); + if (!parsed.success) { + setValidationFailed(true); + return; + } + setValidationFailed(false); + save.mutate(parsed.data); + }} + saveLabel={t("common.save")} + settings={query.data.settings} + /> + {save.isError || validationFailed ? ( +

+ {t("common.networkError")} +

+ ) : null} + + ) : null} +
+ ); +} diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx index 6d91aa71c..2bfac5803 100644 --- a/src/components/settings/record-settings-section-gate.tsx +++ b/src/components/settings/record-settings-section-gate.tsx @@ -1,16 +1,27 @@ "use client"; import type { ReactNode } from "react"; -import { CircleSlash } from "lucide-react"; +import { CircleSlash, Loader2 } from "lucide-react"; +import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { classifySettingsDestination } from "@/lib/record-settings"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status"; +import { ManagedRecordSettingsSection } from "@/components/settings/managed-record-settings-section"; import { SettingsCard } from "@/components/settings/settings-card"; import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; +const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = { + account: "profile", + modules: "modules", + notifications: "notifications", + insights: "insights", + thresholds: "thresholds", + coach: "coach", +} as const; + /** * Stops actor-scoped Settings components from mounting while the browser is * switched into another record. The only managed integration surface is its @@ -23,9 +34,22 @@ export function RecordSettingsSectionGate({ section: SettingsSectionSlug; children: ReactNode; }) { + const { isLoading } = useAuth(); const { inSharedRecord, recordKind } = useRecordCapabilities(); const { t } = useTranslations(); + // A shared session must never begin by mounting the actor's Settings + // component and withdraw it when `/api/auth/me` resolves. The shell holds + // Settings during auth resolution too; this branch keeps direct route use + // equally safe when the gate is rendered independently in a test or story. + if (isLoading) { + return ( + + + + ); + } + if (!inSharedRecord) return children; const destination = classifySettingsDestination(section); @@ -37,6 +61,18 @@ export function RecordSettingsSectionGate({ return ; } + if ( + recordKind === "managed" && + destination.kind === "managed-guardian" && + section in MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION + ) { + const family = + MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION[ + section as keyof typeof MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION + ]; + return ; + } + return ( ["record-settings", recordId, "profile"] as const, + family: (recordId: string, family: string) => + ["record-settings", recordId, family] as const, /** Named aliases stay unambiguous when assembled into `queryKeys`. */ recordSettingsDetail: (recordId: string) => ["record-settings", recordId, "detail"] as const, @@ -19,4 +21,6 @@ export const recordSettingsKeys = { ["record-settings", recordId, "integrations"] as const, recordSettingsProfile: (recordId: string) => ["record-settings", recordId, "profile"] as const, + recordSettingsFamily: (recordId: string, family: string) => + ["record-settings", recordId, family] as const, }; diff --git a/src/lib/record-settings/configuration.ts b/src/lib/record-settings/configuration.ts new file mode 100644 index 000000000..d915a5106 --- /dev/null +++ b/src/lib/record-settings/configuration.ts @@ -0,0 +1,198 @@ +import { z } from "zod/v4"; + +import { + ACCEPTED_INSIGHTS_TILE_IDS, + INSIGHTS_SECTION_IDS, +} from "@/lib/insights-layout"; +import { coachPrefsSchema } from "@/lib/validations/coach-prefs"; +import { modulePrefsPatchSchema } from "@/lib/validations/modules"; +import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; + +/** + * The target-record configuration boundary. These literals are both the DTO + * contract and the reviewable answer to which fields a Guardian may change. + * Identity, credentials, delivery channels, memories, provider connections, + * and every future field fail closed because no family accepts them. + */ +export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = { + profile: [ + "displayName", + "heightCm", + "dateOfBirth", + "gender", + "locale", + "timezone", + "unitPreference", + "timeFormat", + "dateFormat", + ], + modules: ["modulePreferences"], + notifications: ["moodReminderEnabled", "notificationPreferences"], + thresholds: ["overrides"], + coach: ["disableCoach", "preferences"], + insights: ["layout"], +} as const; + +export type ManagedRecordSettingsFamily = + keyof typeof MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST; + +const timezoneSchema = z + .string() + .min(1) + .max(64) + .refine((timezone) => { + try { + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return true; + } catch { + return false; + } + }, "Expected an IANA timezone"); + +const profilePatchSchema = z + .object({ + displayName: z.string().trim().min(1).max(100).nullable().optional(), + heightCm: z.number().finite().min(30).max(300).nullable().optional(), + dateOfBirth: z.string().date().nullable().optional(), + gender: z.string().trim().min(1).max(64).nullable().optional(), + locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable().optional(), + timezone: timezoneSchema.optional(), + unitPreference: z.enum(["metric", "imperial"]).optional(), + timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(), + dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(), + }) + .strict(); + +const insightsLayoutSchema = z + .object({ + version: z.union([z.literal(1), z.literal(2)]), + sections: z + .array( + z + .object({ + id: z.enum(INSIGHTS_SECTION_IDS), + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }) + .strict(), + ) + .max(50) + .optional(), + tiles: z + .array( + z + .object({ + id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS), + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }) + .strict(), + ) + .min(1) + .max(ACCEPTED_INSIGHTS_TILE_IDS.length) + .optional(), + }) + .strict(); + +// Delivery ownership is the actor's personal channel choice. A Guardian may +// set only the record's reminder timing and low-stock thresholds, never hand +// a managed record to an actor's phone or provider channel. +const managedNotificationPreferencesSchema = z + .object({ + medication: z + .object({ + lowStockRunwayDays: z.number().int().min(1).max(60).nullable(), + reorderLeadDays: z.number().int().min(0).max(60), + }) + .strict() + .partial() + .optional(), + mood: z + .object({ reminderHour: z.number().int().min(0).max(23) }) + .strict() + .partial() + .optional(), + }) + .strict(); + +const managedCoachPreferencesSchema = coachPrefsSchema + .pick({ + tone: true, + verbosity: true, + excludeMetrics: true, + showEvidenceByDefault: true, + defaultWindow: true, + dataClusters: true, + }) + .strict(); + +const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = { + profile: profilePatchSchema, + modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(), + notifications: z + .object({ + moodReminderEnabled: z.boolean().optional(), + notificationPreferences: managedNotificationPreferencesSchema.optional(), + }) + .strict(), + thresholds: z.object({ overrides: thresholdsUpdateSchema }).strict(), + coach: z + .object({ + disableCoach: z.boolean().optional(), + preferences: managedCoachPreferencesSchema.optional(), + }) + .strict(), + insights: z.object({ layout: insightsLayoutSchema }).strict(), +} as const; + +export type ManagedRecordSettingsPatch = { + profile: z.infer; + modules: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["modules"]>; + notifications: z.infer< + (typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["notifications"] + >; + thresholds: z.infer< + (typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["thresholds"] + >; + coach: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["coach"]>; + insights: z.infer<(typeof MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS)["insights"]>; +}; + +export function isManagedRecordSettingsFamily( + value: string, +): value is ManagedRecordSettingsFamily { + return value in MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS; +} + +/** Parse one named DTO family. Strict schemas make an actor field a 422. */ +export function parseManagedRecordSettingsPatch< + Family extends ManagedRecordSettingsFamily, +>(family: Family, value: unknown): ManagedRecordSettingsPatch[Family] { + return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].parse( + value, + ) as ManagedRecordSettingsPatch[Family]; +} + +export function safeParseManagedRecordSettingsPatch< + Family extends ManagedRecordSettingsFamily, +>(family: Family, value: unknown) { + return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].safeParse(value); +} + +/** Preserve only directly-owned module keys from a legacy persisted blob. */ +export function managedModulePreferencesFrom( + raw: unknown, +): ManagedRecordSettingsPatch["modules"]["modulePreferences"] { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + + const allowed: Record = {}; + for (const [key, value] of Object.entries(raw)) { + const parsed = modulePrefsPatchSchema.safeParse({ [key]: value }); + if (parsed.success && value !== undefined) { + allowed[key] = value as boolean; + } + } + return allowed; +} diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts index ba08366e8..f234d190b 100644 --- a/src/lib/record-settings/index.ts +++ b/src/lib/record-settings/index.ts @@ -12,3 +12,13 @@ export { type SettingsDestinationClassification, type SettingsDestinationKind, } from "./classification"; +export { + isManagedRecordSettingsFamily, + managedModulePreferencesFrom, + MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST, + parseManagedRecordSettingsPatch, + safeParseManagedRecordSettingsPatch, + type ManagedRecordSettingsFamily, + type ManagedRecordSettingsPatch, +} from "./configuration"; +export { assertRecordSettingsResponseForRecord } from "./response"; diff --git a/src/lib/record-settings/response.ts b/src/lib/record-settings/response.ts new file mode 100644 index 000000000..4098d931f --- /dev/null +++ b/src/lib/record-settings/response.ts @@ -0,0 +1,14 @@ +/** + * A request carries its active record through the session cookie, so the + * client must still reject a response that arrives after a tab switched to a + * different record. TanStack keys prevent cache aliasing; this closes the + * in-flight response race before it can populate that key. + */ +export function assertRecordSettingsResponseForRecord( + response: { recordId: string }, + expectedRecordId: string | null, +): void { + if (expectedRecordId === null || response.recordId !== expectedRecordId) { + throw new Error("Managed record settings response did not match its key"); + } +} diff --git a/tests/integration/record-settings-authorization.test.ts b/tests/integration/record-settings-authorization.test.ts index 838bcfc6b..ebc4adf6f 100644 --- a/tests/integration/record-settings-authorization.test.ts +++ b/tests/integration/record-settings-authorization.test.ts @@ -8,6 +8,7 @@ import { classifySettingsDestination, SETTINGS_DESTINATION_INVENTORY, } from "@/lib/record-settings/classification"; +import { MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST } from "@/lib/record-settings"; const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); @@ -48,4 +49,34 @@ describe("record settings authorization", () => { "unavailable", ); }); + + it("uses one guardian-only record resolver for every writable DTO family", async () => { + const route = await readFile( + fromRoot("src/app/api/record-settings/[family]/route.ts"), + "utf8", + ); + const guardian = resolveGuardianRecordSettingsAccess({ + actor: { id: "guardian-1" }, + grantId: "grant-1", + user: { id: "managed-record-1", managedProfileAt: new Date() }, + }); + const adultManager = resolveGuardianRecordSettingsAccess({ + actor: { id: "adult-manager" }, + grantId: "grant-1", + user: { id: "adult-record-1", managedProfileAt: null }, + }); + + expect(guardian?.recordId).toBe("managed-record-1"); + expect(adultManager).toBeNull(); + expect(route).toContain("requireGuardianAuth"); + expect(route).toContain("resolveGuardianRecordSettingsAccess"); + for (const family of Object.keys(MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST)) { + expect(route).toContain(`case \"${family}\"`); + } + expect(route).toContain("record-settings.${family}.update"); + expect(route).toContain("userId: access.recordId"); + expect(route).toContain("actorUserId: access.actorId"); + expect(route).toContain("details: { changed }"); + expect(route).not.toContain("details: { recordId:"); + }); }); From 247ac8494c10980c2be33a01dca648c01fa6fefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 03:40:13 +0200 Subject: [PATCH 09/12] fix: complete managed record settings - Expose canonical defaults and reject invalid settings families\n- Localize Guardian controls and refresh dependent caches --- messages/de.json | 70 +++++ messages/en.json | 70 +++++ messages/es.json | 70 +++++ messages/fr.json | 70 +++++ messages/it.json | 70 +++++ messages/pl.json | 70 +++++ .../record-settings-configuration.test.ts | 29 ++ .../record-settings-route-guard.test.ts | 5 + src/__tests__/record-settings-ui.test.tsx | 48 +++- src/app/api/record-settings/[family]/route.ts | 19 +- .../managed-record-settings-section.test.tsx | 42 ++- .../managed-record-settings-section.tsx | 254 +++++++++++++----- src/lib/record-settings/configuration.ts | 27 +- src/lib/record-settings/index.ts | 1 + src/lib/validations/modules.ts | 2 +- .../record-settings-authorization.test.ts | 3 + 16 files changed, 759 insertions(+), 91 deletions(-) diff --git a/messages/de.json b/messages/de.json index faa9ce89e..42cf23cf1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4434,6 +4434,76 @@ } }, "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profil", + "displayName": "Anzeigename", + "height": "Größe (cm)", + "dateOfBirth": "Geburtsdatum", + "gender": "Geschlecht", + "notSpecified": "Nicht angegeben", + "male": "Männlich", + "female": "Weiblich", + "other": "Andere Angabe", + "locale": "Sprache", + "systemDefault": "Systemstandard", + "timezone": "Zeitzone", + "units": "Einheiten", + "metric": "Metrisch", + "imperial": "Imperial", + "timeFormat": "Zeitformat", + "automatic": "Automatisch", + "hour12": "12 Stunden", + "hour24": "24 Stunden", + "dateFormat": "Datumsformat", + "dateDmy": "Tag / Monat / Jahr", + "dateMdy": "Monat / Tag / Jahr", + "dateYmd": "Jahr / Monat / Tag" + }, + "modules": { "title": "Module" }, + "notifications": { + "title": "Benachrichtigungen", + "moodReminder": "Stimmungserinnerung aktivieren", + "reminderHour": "Uhrzeit der Stimmungserinnerung", + "lowStockRunwayDays": "Tage bis zum niedrigen Bestand", + "reorderLeadDays": "Vorlaufzeit für Nachbestellung" + }, + "thresholds": { + "title": "Zielwerte", + "metric": "Messwert", + "minimum": "Minimum", + "maximum": "Maximum" + }, + "coach": { + "title": "Coach", + "disable": "Coach deaktivieren", + "tone": "Ton", + "warm": "Herzlich", + "neutral": "Neutral", + "concise": "Knapp", + "verbosity": "Ausführlichkeit", + "brief": "Kurz", + "default": "Standard", + "detailed": "Ausführlich", + "defaultWindow": "Standardzeitraum", + "last7days": "Letzte 7 Tage", + "last30days": "Letzte 30 Tage", + "last90days": "Letzte 90 Tage", + "allTime": "Gesamter Zeitraum", + "showEvidence": "Evidenz standardmäßig anzeigen", + "excludedMetrics": "Ausgeschlossene Messwerte", + "dataClusters": "Datenbereiche", + "customClusters": "Eigene Auswahl von Datenbereichen verwenden", + "value": "{value}" + }, + "insights": { + "title": "Erkenntnisse", + "sections": "Übersichtsbereiche", + "tiles": "Navigationskacheln", + "item": "{id}", + "order": "Reihenfolge" + } + }, "unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar", "unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.", "integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar." diff --git a/messages/en.json b/messages/en.json index b40757598..1339c30d4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4434,6 +4434,76 @@ } }, "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profile", + "displayName": "Display name", + "height": "Height (cm)", + "dateOfBirth": "Date of birth", + "gender": "Gender", + "notSpecified": "Not specified", + "male": "Male", + "female": "Female", + "other": "Other", + "locale": "Locale", + "systemDefault": "System default", + "timezone": "Timezone", + "units": "Units", + "metric": "Metric", + "imperial": "Imperial", + "timeFormat": "Time format", + "automatic": "Automatic", + "hour12": "12-hour", + "hour24": "24-hour", + "dateFormat": "Date format", + "dateDmy": "Day / month / year", + "dateMdy": "Month / day / year", + "dateYmd": "Year / month / day" + }, + "modules": { "title": "Modules" }, + "notifications": { + "title": "Notifications", + "moodReminder": "Enable mood reminder", + "reminderHour": "Mood reminder hour", + "lowStockRunwayDays": "Low-stock runway days", + "reorderLeadDays": "Reorder lead days" + }, + "thresholds": { + "title": "Targets", + "metric": "Metric", + "minimum": "Minimum", + "maximum": "Maximum" + }, + "coach": { + "title": "Coach", + "disable": "Disable Coach", + "tone": "Tone", + "warm": "Warm", + "neutral": "Neutral", + "concise": "Concise", + "verbosity": "Verbosity", + "brief": "Brief", + "default": "Default", + "detailed": "Detailed", + "defaultWindow": "Default window", + "last7days": "Last 7 days", + "last30days": "Last 30 days", + "last90days": "Last 90 days", + "allTime": "All time", + "showEvidence": "Show evidence by default", + "excludedMetrics": "Excluded metrics", + "dataClusters": "Data clusters", + "customClusters": "Use a custom cluster selection", + "value": "{value}" + }, + "insights": { + "title": "Insights", + "sections": "Overview sections", + "tiles": "Navigation tiles", + "item": "{id}", + "order": "Order" + } + }, "unavailableTitle": "Unavailable in this shared record", "unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.", "integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable." diff --git a/messages/es.json b/messages/es.json index ca5332aed..3364f7f2a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4434,6 +4434,76 @@ } }, "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Perfil", + "displayName": "Nombre visible", + "height": "Altura (cm)", + "dateOfBirth": "Fecha de nacimiento", + "gender": "Género", + "notSpecified": "No especificado", + "male": "Hombre", + "female": "Mujer", + "other": "Otro", + "locale": "Idioma", + "systemDefault": "Predeterminado del sistema", + "timezone": "Zona horaria", + "units": "Unidades", + "metric": "Métrico", + "imperial": "Imperial", + "timeFormat": "Formato de hora", + "automatic": "Automático", + "hour12": "12 horas", + "hour24": "24 horas", + "dateFormat": "Formato de fecha", + "dateDmy": "Día / mes / año", + "dateMdy": "Mes / día / año", + "dateYmd": "Año / mes / día" + }, + "modules": { "title": "Módulos" }, + "notifications": { + "title": "Notificaciones", + "moodReminder": "Activar recordatorio de ánimo", + "reminderHour": "Hora del recordatorio de ánimo", + "lowStockRunwayDays": "Días hasta existencias bajas", + "reorderLeadDays": "Días de margen para reponer" + }, + "thresholds": { + "title": "Objetivos", + "metric": "Métrica", + "minimum": "Mínimo", + "maximum": "Máximo" + }, + "coach": { + "title": "Asistente", + "disable": "Desactivar asistente", + "tone": "Tono", + "warm": "Cálido", + "neutral": "Neutro", + "concise": "Conciso", + "verbosity": "Nivel de detalle", + "brief": "Breve", + "default": "Predeterminado", + "detailed": "Detallado", + "defaultWindow": "Periodo predeterminado", + "last7days": "Últimos 7 días", + "last30days": "Últimos 30 días", + "last90days": "Últimos 90 días", + "allTime": "Todo el tiempo", + "showEvidence": "Mostrar evidencia de forma predeterminada", + "excludedMetrics": "Métricas excluidas", + "dataClusters": "Grupos de datos", + "customClusters": "Usar una selección personalizada de grupos", + "value": "{value}" + }, + "insights": { + "title": "Análisis", + "sections": "Secciones del resumen", + "tiles": "Mosaicos de navegación", + "item": "{id}", + "order": "Orden" + } + }, "unavailableTitle": "No disponible en este registro compartido", "unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.", "integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles." diff --git a/messages/fr.json b/messages/fr.json index 34de11f03..601b80743 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4434,6 +4434,76 @@ } }, "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profil", + "displayName": "Nom affiché", + "height": "Taille (cm)", + "dateOfBirth": "Date de naissance", + "gender": "Genre", + "notSpecified": "Non précisé", + "male": "Homme", + "female": "Femme", + "other": "Autre", + "locale": "Langue", + "systemDefault": "Valeur système", + "timezone": "Fuseau horaire", + "units": "Unités", + "metric": "Métrique", + "imperial": "Impérial", + "timeFormat": "Format de l’heure", + "automatic": "Automatique", + "hour12": "12 heures", + "hour24": "24 heures", + "dateFormat": "Format de date", + "dateDmy": "Jour / mois / année", + "dateMdy": "Mois / jour / année", + "dateYmd": "Année / mois / jour" + }, + "modules": { "title": "Modules" }, + "notifications": { + "title": "Notifications", + "moodReminder": "Activer le rappel d’humeur", + "reminderHour": "Heure du rappel d’humeur", + "lowStockRunwayDays": "Jours avant stock faible", + "reorderLeadDays": "Délai de réapprovisionnement" + }, + "thresholds": { + "title": "Objectifs", + "metric": "Mesure", + "minimum": "Minimum", + "maximum": "Maximum" + }, + "coach": { + "title": "Coach", + "disable": "Désactiver le Coach", + "tone": "Ton", + "warm": "Chaleureux", + "neutral": "Neutre", + "concise": "Concis", + "verbosity": "Niveau de détail", + "brief": "Bref", + "default": "Par défaut", + "detailed": "Détaillé", + "defaultWindow": "Période par défaut", + "last7days": "7 derniers jours", + "last30days": "30 derniers jours", + "last90days": "90 derniers jours", + "allTime": "Depuis toujours", + "showEvidence": "Afficher les sources par défaut", + "excludedMetrics": "Mesures exclues", + "dataClusters": "Groupes de données", + "customClusters": "Utiliser une sélection personnalisée de groupes", + "value": "{value}" + }, + "insights": { + "title": "Analyses", + "sections": "Sections d’aperçu", + "tiles": "Tuiles de navigation", + "item": "{id}", + "order": "Ordre" + } + }, "unavailableTitle": "Indisponible dans ce dossier partagé", "unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.", "integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles." diff --git a/messages/it.json b/messages/it.json index 3e2b9d38f..6b0b5d2c2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4434,6 +4434,76 @@ } }, "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profilo", + "displayName": "Nome visualizzato", + "height": "Altezza (cm)", + "dateOfBirth": "Data di nascita", + "gender": "Genere", + "notSpecified": "Non specificato", + "male": "Uomo", + "female": "Donna", + "other": "Altro", + "locale": "Lingua", + "systemDefault": "Predefinito di sistema", + "timezone": "Fuso orario", + "units": "Unità", + "metric": "Metrico", + "imperial": "Imperiale", + "timeFormat": "Formato ora", + "automatic": "Automatico", + "hour12": "12 ore", + "hour24": "24 ore", + "dateFormat": "Formato data", + "dateDmy": "Giorno / mese / anno", + "dateMdy": "Mese / giorno / anno", + "dateYmd": "Anno / mese / giorno" + }, + "modules": { "title": "Moduli" }, + "notifications": { + "title": "Notifiche", + "moodReminder": "Attiva promemoria dell’umore", + "reminderHour": "Ora del promemoria dell’umore", + "lowStockRunwayDays": "Giorni prima delle scorte basse", + "reorderLeadDays": "Margine per il riordino" + }, + "thresholds": { + "title": "Obiettivi", + "metric": "Metrica", + "minimum": "Minimo", + "maximum": "Massimo" + }, + "coach": { + "title": "Coach", + "disable": "Disattiva Coach", + "tone": "Tono", + "warm": "Caldo", + "neutral": "Neutro", + "concise": "Conciso", + "verbosity": "Livello di dettaglio", + "brief": "Breve", + "default": "Predefinito", + "detailed": "Dettagliato", + "defaultWindow": "Periodo predefinito", + "last7days": "Ultimi 7 giorni", + "last30days": "Ultimi 30 giorni", + "last90days": "Ultimi 90 giorni", + "allTime": "Tutto il periodo", + "showEvidence": "Mostra le fonti per impostazione predefinita", + "excludedMetrics": "Metriche escluse", + "dataClusters": "Gruppi di dati", + "customClusters": "Usa una selezione personalizzata di gruppi", + "value": "{value}" + }, + "insights": { + "title": "Analisi", + "sections": "Sezioni della panoramica", + "tiles": "Riquadri di navigazione", + "item": "{id}", + "order": "Ordine" + } + }, "unavailableTitle": "Non disponibile in questo record condiviso", "unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.", "integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili." diff --git a/messages/pl.json b/messages/pl.json index ad45b25c4..6f1cdc657 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4434,6 +4434,76 @@ } }, "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profil", + "displayName": "Wyświetlana nazwa", + "height": "Wzrost (cm)", + "dateOfBirth": "Data urodzenia", + "gender": "Płeć", + "notSpecified": "Nie podano", + "male": "Mężczyzna", + "female": "Kobieta", + "other": "Inna", + "locale": "Język", + "systemDefault": "Ustawienie systemowe", + "timezone": "Strefa czasowa", + "units": "Jednostki", + "metric": "Metryczne", + "imperial": "Imperialne", + "timeFormat": "Format czasu", + "automatic": "Automatyczny", + "hour12": "12-godzinny", + "hour24": "24-godzinny", + "dateFormat": "Format daty", + "dateDmy": "Dzień / miesiąc / rok", + "dateMdy": "Miesiąc / dzień / rok", + "dateYmd": "Rok / miesiąc / dzień" + }, + "modules": { "title": "Moduły" }, + "notifications": { + "title": "Powiadomienia", + "moodReminder": "Włącz przypomnienie nastroju", + "reminderHour": "Godzina przypomnienia nastroju", + "lowStockRunwayDays": "Dni do niskiego stanu zapasów", + "reorderLeadDays": "Wyprzedzenie ponownego zamówienia" + }, + "thresholds": { + "title": "Cele", + "metric": "Metryka", + "minimum": "Minimum", + "maximum": "Maksimum" + }, + "coach": { + "title": "Trener", + "disable": "Wyłącz trenera", + "tone": "Ton", + "warm": "Ciepły", + "neutral": "Neutralny", + "concise": "Zwięzły", + "verbosity": "Szczegółowość", + "brief": "Krótka", + "default": "Domyślna", + "detailed": "Szczegółowa", + "defaultWindow": "Domyślny okres", + "last7days": "Ostatnie 7 dni", + "last30days": "Ostatnie 30 dni", + "last90days": "Ostatnie 90 dni", + "allTime": "Cały okres", + "showEvidence": "Domyślnie pokazuj źródła", + "excludedMetrics": "Wykluczone metryki", + "dataClusters": "Grupy danych", + "customClusters": "Użyj własnego wyboru grup", + "value": "{value}" + }, + "insights": { + "title": "Wnioski", + "sections": "Sekcje przeglądu", + "tiles": "Kafelki nawigacji", + "item": "{id}", + "order": "Kolejność" + } + }, "unavailableTitle": "Niedostępne w tym udostępnionym rekordzie", "unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.", "integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne." diff --git a/src/__tests__/record-settings-configuration.test.ts b/src/__tests__/record-settings-configuration.test.ts index 27c078dc4..f967fa668 100644 --- a/src/__tests__/record-settings-configuration.test.ts +++ b/src/__tests__/record-settings-configuration.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { + isManagedRecordSettingsFamily, MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST, + MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, + managedModulePreferencesFrom, parseManagedRecordSettingsPatch, } from "@/lib/record-settings/configuration"; +import { WRITABLE_MODULE_KEYS } from "@/lib/validations/modules"; describe("managed record settings configuration contract", () => { it("enumerates the only target-record fields each settings DTO may write", () => { @@ -95,4 +99,29 @@ describe("managed record settings configuration contract", () => { ] as const)("accepts a typed %s patch", (family, patch) => { expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow(); }); + + it("uses the complete canonical module inventory for a fresh record", () => { + expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual( + WRITABLE_MODULE_KEYS, + ); + expect(managedModulePreferencesFrom(null)).toEqual( + MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, + ); + }); + + it("accepts only the canonical profile gender enum", () => { + expect(() => + parseManagedRecordSettingsPatch("profile", { gender: "MALE" }), + ).not.toThrow(); + expect(() => + parseManagedRecordSettingsPatch("profile", { gender: "UNKNOWN" }), + ).toThrow(); + }); + + it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])( + "rejects inherited family name %s", + (family) => { + expect(isManagedRecordSettingsFamily(family)).toBe(false); + }, + ); }); diff --git a/src/__tests__/record-settings-route-guard.test.ts b/src/__tests__/record-settings-route-guard.test.ts index 9d398a045..6435fadb1 100644 --- a/src/__tests__/record-settings-route-guard.test.ts +++ b/src/__tests__/record-settings-route-guard.test.ts @@ -57,5 +57,10 @@ describe("record settings route guard", () => { for (const family of families) { expect(source).toContain(`case \"${family}\"`); } + expect(source).toContain("if (!isManagedRecordSettingsFamily(rawFamily))"); + expect(source).toContain('apiError("Settings family not found", 404)'); + expect( + source.indexOf("isManagedRecordSettingsFamily(rawFamily)"), + ).toBeLessThan(source.indexOf("const context = await requireGuardianAuth")); }); }); diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index 92b6fd56e..5d9e17ef3 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -5,6 +5,27 @@ import { describe, expect, it } from "vitest"; const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments); +function expectMatchingMessageKeys(expected: unknown, actual: unknown): void { + if ( + expected === null || + typeof expected !== "object" || + actual === null || + typeof actual !== "object" + ) { + expect(typeof actual).toBe(typeof expected); + return; + } + + const expectedRecord = expected as Record; + const actualRecord = actual as Record; + expect(Object.keys(actualRecord).sort()).toEqual( + Object.keys(expectedRecord).sort(), + ); + for (const key of Object.keys(expectedRecord)) { + expectMatchingMessageKeys(expectedRecord[key], actualRecord[key]); + } +} + describe("managed record settings UI", () => { it("gates shared deep links and renders integration status without controls", async () => { const [ @@ -54,10 +75,31 @@ describe("managed record settings UI", () => { expect(managedSettingsView).not.toContain("/api/auth/"); expect(managedSettingsView).not.toContain("/api/user/"); expect(managedSettingsView).not.toContain(", ) => renderToStaticMarkup( - , + + + , ); describe("managed record settings forms", () => { @@ -62,4 +68,26 @@ describe("managed record settings forms", () => { expect(html).not.toContain(" { + const modules = render("modules", { modulePreferences: {} }); + const thresholds = render("thresholds", { + overrides: {}, + effective: Object.fromEntries( + ALL_METRICS.map((metric) => [ + metric, + { range: { greenMin: 10, greenMax: 20 } }, + ]), + ), + }); + + for (const module of Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)) { + expect(modules).toContain(`name="${module}"`); + } + for (const metric of ALL_METRICS) { + expect(thresholds).toContain(`value="${metric}"`); + } + expect(thresholds).toContain('value="10"'); + expect(thresholds).toContain('value="20"'); + }); }); diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index ae9fc3608..df97bd33a 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -7,12 +7,19 @@ import { Settings } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { MODULE_REGISTRY } from "@/lib/modules/registry"; import { queryKeys } from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord, + MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, safeParseManagedRecordSettingsPatch, type ManagedRecordSettingsFamily, } from "@/lib/record-settings"; +import { + METRIC_BOUNDS, + type ThresholdMetric, +} from "@/lib/analytics/effective-range"; +import { ALL_METRICS } from "@/lib/validations/thresholds"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SettingsCardHeader } from "./_card-header"; @@ -60,6 +67,22 @@ const COACH_DATA_CLUSTERS = [ "mobility", "environment", ] as const; +const METRIC_LABEL_KEYS: Record = { + WEIGHT: "thresholds.metricWeight", + BLOOD_PRESSURE_SYS: "thresholds.metricBpSys", + BLOOD_PRESSURE_DIA: "thresholds.metricBpDia", + PULSE: "thresholds.metricPulse", + BODY_FAT: "thresholds.metricBodyFat", + SLEEP_DURATION: "thresholds.metricSleep", + ACTIVITY_STEPS: "thresholds.metricSteps", + BLOOD_GLUCOSE_FASTING: "thresholds.metricGlucoseFasting", + BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial", + BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom", + BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime", + TOTAL_BODY_WATER: "thresholds.metricBodyWater", + BONE_MASS: "thresholds.metricBoneMass", + OXYGEN_SATURATION: "thresholds.metricOxygenSaturation", +}; function asRecord(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -100,6 +123,7 @@ function ProfileSettingsForm({ onSave, saveLabel, }: SettingsFormProps) { + const { t } = useTranslations(); function submit(event: FormEvent) { event.preventDefault(); const form = new FormData(event.currentTarget); @@ -121,7 +145,7 @@ function ProfileSettingsForm({ return (
@@ -254,10 +306,12 @@ function ModulesSettingsForm({ onSave, saveLabel, }: SettingsFormProps) { + const { t } = useTranslations(); const preferences = asRecord(settings.modulePreferences); - const entries = Object.entries(preferences).sort(([left], [right]) => - left.localeCompare(right), - ); + const entries = Object.entries(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS) as [ + keyof typeof MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, + boolean, + ][]; function submit(event: FormEvent) { event.preventDefault(); @@ -271,15 +325,19 @@ function ModulesSettingsForm({ return ( - {entries.map(([key, enabled]) => ( + {entries.map(([key, defaultEnabled]) => ( ))} @@ -293,6 +351,7 @@ function NotificationsSettingsForm({ onSave, saveLabel, }: SettingsFormProps) { + const { t } = useTranslations(); const preferences = asRecord(settings.notificationPreferences); const medication = asRecord(preferences.medication); const mood = asRecord(preferences.mood); @@ -324,10 +383,10 @@ function NotificationsSettingsForm({ name="moodReminderEnabled" type="checkbox" /> - Enable mood reminder + {t("settings.sharedRecord.managedSettings.notifications.moodReminder")}
- Excluded metrics + + {t("settings.sharedRecord.managedSettings.coach.excludedMetrics")} + {COACH_EXCLUDE_METRICS.map((metric) => ( ))}
- Data clusters + + {t("settings.sharedRecord.managedSettings.coach.dataClusters")} + {COACH_DATA_CLUSTERS.map((cluster) => ( ))}
@@ -622,6 +727,7 @@ function InsightsSettingsForm({ onSave, saveLabel, }: SettingsFormProps) { + const { t } = useTranslations(); const layout = asRecord(settings.layout); const sections = layoutItems(layout.sections); const tiles = layoutItems(layout.tiles); @@ -646,7 +752,9 @@ function InsightsSettingsForm({ const renderItems = (kind: "section" | "tile", items: LayoutItem[]) => (
- {kind === "section" ? "Overview sections" : "Navigation tiles"} + {kind === "section" + ? t("settings.sharedRecord.managedSettings.insights.sections") + : t("settings.sharedRecord.managedSettings.insights.tiles")} {items.map((item) => (
@@ -657,13 +765,15 @@ function InsightsSettingsForm({ name={`${kind}-visible-${item.id}`} type="checkbox" /> - {item.id} + {t("settings.sharedRecord.managedSettings.insights.item", { + id: item.id, + })}
@@ -691,9 +785,7 @@ function CoachSettingsForm({ name={`cluster-${cluster}`} type="checkbox" /> - {t("settings.sharedRecord.managedSettings.coach.value", { - value: cluster, - })} + {coachClusterLabel(t, cluster)} ))} @@ -765,9 +857,7 @@ function InsightsSettingsForm({ name={`${kind}-visible-${item.id}`} type="checkbox" /> - {t("settings.sharedRecord.managedSettings.insights.item", { - id: item.id, - })} + {insightItemLabel(t, kind, item.id)}