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:"); + }); });