feat: configure managed record settings

- Scope writable settings to the selected managed record\n- Render typed Guardian controls with separated audit attribution
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:21:02 +02:00
1 parent b639b064f9
commit e788675b82
19 files changed
+1879 -29

No files matched your search

@@ -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();
});
});
@@ -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();
});
});
@@ -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}\"`);
}
});
});
+39 -15
View File
@@ -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("<Button");
expect(statusView).not.toContain("<Link");
expect(settingsPage).toContain("RecordSettingsSectionGate");
expect(sectionGate).toContain("if (!inSharedRecord) return children");
expect(sectionGate).toContain("ManagedIntegrationStatus");
expect(sectionGate).toContain("ManagedRecordSettingsSection");
expect(sectionGate).toContain("if (isLoading)");
expect(layoutModulePage).toContain(
'RecordSettingsSectionGate section="layout"',
);
expect(managedSettingsView).toContain(
"safeParseManagedRecordSettingsPatch",
);
expect(managedSettingsView).toContain("/api/record-settings/${family}");
expect(managedSettingsView).not.toContain("/api/auth/");
expect(managedSettingsView).not.toContain("/api/user/");
expect(managedSettingsView).not.toContain("<textarea");
for (const bundle of localeBundles) {
expect(bundle).toContain('"sharedRecord"');
expect(bundle).toContain('"unavailableTitle"');
+5 -2
View File
@@ -1750,6 +1750,8 @@ const GUARDIAN_ROUTES: Record<string, string> = {
"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<string, string> = {
* 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` —
@@ -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;
}
},
);
+8 -5
View File
@@ -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,
}}
>
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleGate={group.moduleGate}>
<Body />
</LayoutModuleGate>
</section>
<RecordSettingsSectionGate section="layout">
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleGate={group.moduleGate}>
<Body />
</LayoutModuleGate>
</section>
</RecordSettingsSectionGate>
</SettingsShell>
);
}
@@ -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("<AuthShell> — 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");
});
});
+10 -3
View File
@@ -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 (
<div className="flex h-dvh items-center justify-center" role="status">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
@@ -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<typeof ManagedRecordSettingsForm>[0]["family"],
settings: Record<string, unknown>,
) =>
renderToStaticMarkup(
<ManagedRecordSettingsForm
disabled={false}
family={family}
onSave={vi.fn()}
saveLabel="Save"
settings={settings}
/>,
);
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("<textarea");
},
);
});
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const authRef: {
isLoading: boolean;
active: {
accountId: string;
recordKind: "managed" | "shared";
level: "manage";
sections: null;
canWrite: true;
} | null;
} = {
isLoading: true,
active: null,
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isLoading: authRef.isLoading,
user: authRef.active
? {
accountAccess: {
active: authRef.active,
accounts: [authRef.active],
canSwitch: true,
},
}
: null,
}),
}));
vi.mock("../managed-record-settings-section", () => ({
ManagedRecordSettingsSection: () => <div data-slot="managed-settings" />,
}));
vi.mock("../managed-integration-status", () => ({
ManagedIntegrationStatus: () => <div data-slot="managed-integrations" />,
}));
import { RecordSettingsSectionGate } from "../record-settings-section-gate";
function render(section: "modules" | "integrations") {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RecordSettingsSectionGate section={section}>
<div data-slot="actor-settings" />
</RecordSettingsSectionGate>
</I18nProvider>,
);
}
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");
});
});
@@ -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<ManagedIntegrationStatusResponse>(
queryFn: async () => {
const response = await apiGet<ManagedIntegrationStatusResponse>(
"/api/record-settings/integrations",
),
);
assertRecordSettingsResponseForRecord(response, recordId);
return response;
},
enabled: activeRecord?.recordKind === "managed" && recordId !== null,
});
@@ -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<string, unknown>;
}
type ManagedSettingsFamily = Exclude<
ManagedRecordSettingsFamily,
"integrations"
>;
type SettingsFormProps = {
settings: Record<string, unknown>;
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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 (
<Button disabled={disabled} type="submit">
{label}
</Button>
);
}
function ProfileSettingsForm({
settings,
disabled,
onSave,
saveLabel,
}: SettingsFormProps) {
function submit(event: FormEvent<HTMLFormElement>) {
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 (
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<label className="grid gap-1 text-sm" htmlFor="managed-display-name">
Display name
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.displayName)}
disabled={disabled}
id="managed-display-name"
name="displayName"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-height">
Height (cm)
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={
settings.heightCm === null ? "" : String(settings.heightCm ?? "")
}
disabled={disabled}
id="managed-height"
max="300"
min="30"
name="heightCm"
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-of-birth">
Date of birth
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.dateOfBirth)}
disabled={disabled}
id="managed-date-of-birth"
name="dateOfBirth"
type="date"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
Gender
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.gender)}
disabled={disabled}
id="managed-gender"
name="gender"
>
<option value="">Not specified</option>
<option value="MALE">Male</option>
<option value="FEMALE">Female</option>
<option value="OTHER">Other</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-locale">
Locale
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.locale)}
disabled={disabled}
id="managed-locale"
name="locale"
>
<option value="">System default</option>
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
Timezone
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.timezone, "Europe/Berlin")}
disabled={disabled}
id="managed-timezone"
name="timezone"
required
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-units">
Units
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.unitPreference, "metric")}
disabled={disabled}
id="managed-units"
name="unitPreference"
>
<option value="metric">Metric</option>
<option value="imperial">Imperial</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-time-format">
Time format
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.timeFormat, "AUTO")}
disabled={disabled}
id="managed-time-format"
name="timeFormat"
>
<option value="AUTO">Automatic</option>
<option value="H12">12-hour</option>
<option value="H24">24-hour</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-format">
Date format
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.dateFormat, "AUTO")}
disabled={disabled}
id="managed-date-format"
name="dateFormat"
>
<option value="AUTO">Automatic</option>
<option value="DMY">Day / month / year</option>
<option value="MDY">Month / day / year</option>
<option value="YMD">Year / month / day</option>
</select>
</label>
<div className="sm:col-span-2">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
</form>
);
}
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<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
onSave({
modulePreferences: Object.fromEntries(
entries.map(([key]) => [key, form.get(key) === "on"]),
),
});
}
return (
<form className="mt-4 space-y-3" onSubmit={submit}>
{entries.map(([key, enabled]) => (
<label className="flex items-center gap-3 text-sm" key={key}>
<input
defaultChecked={enabled === true}
disabled={disabled}
name={key}
type="checkbox"
/>
{key}
</label>
))}
<SaveButton disabled={disabled} label={saveLabel} />
</form>
);
}
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<HTMLFormElement>) {
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 (
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<label className="flex items-center gap-3 text-sm sm:col-span-2">
<input
defaultChecked={settings.moodReminderEnabled === true}
disabled={disabled}
name="moodReminderEnabled"
type="checkbox"
/>
Enable mood reminder
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
Mood reminder hour
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(mood.reminderHour, 22)}
disabled={disabled}
id="managed-reminder-hour"
max="23"
min="0"
name="reminderHour"
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-low-stock">
Low-stock runway days
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={
medication.lowStockRunwayDays === null
? ""
: asNumber(medication.lowStockRunwayDays, 7)
}
disabled={disabled}
id="managed-low-stock"
max="60"
min="1"
name="lowStockRunwayDays"
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-reorder-lead">
Reorder lead days
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(medication.reorderLeadDays, 10)}
disabled={disabled}
id="managed-reorder-lead"
max="60"
min="0"
name="reorderLeadDays"
type="number"
/>
</label>
<div className="sm:col-span-2">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
</form>
);
}
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<HTMLFormElement>) {
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 (
<form className="mt-4 grid gap-4 sm:grid-cols-3" onSubmit={submit}>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-metric">
Metric
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={metric}
disabled={disabled}
id="managed-threshold-metric"
name="metric"
>
{[...new Set([metric, ...metrics])].map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
Minimum
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(firstRange.min, 0)}
disabled={disabled}
id="managed-threshold-minimum"
name="minimum"
required
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-maximum">
Maximum
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(firstRange.max, 0)}
disabled={disabled}
id="managed-threshold-maximum"
name="maximum"
required
type="number"
/>
</label>
<div className="sm:col-span-3">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
</form>
);
}
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<HTMLFormElement>) {
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 (
<form className="mt-4 space-y-4" onSubmit={submit}>
<label className="flex items-center gap-3 text-sm">
<input
defaultChecked={settings.disableCoach === true}
disabled={disabled}
name="disableCoach"
type="checkbox"
/>
Disable Coach
</label>
<div className="grid gap-4 sm:grid-cols-3">
<label className="grid gap-1 text-sm" htmlFor="managed-coach-tone">
Tone
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(preferences.tone, "warm")}
disabled={disabled}
id="managed-coach-tone"
name="tone"
>
<option value="warm">Warm</option>
<option value="neutral">Neutral</option>
<option value="concise">Concise</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-verbosity">
Verbosity
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(preferences.verbosity, "default")}
disabled={disabled}
id="managed-coach-verbosity"
name="verbosity"
>
<option value="brief">Brief</option>
<option value="default">Default</option>
<option value="detailed">Detailed</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
Default window
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(preferences.defaultWindow, "allTime")}
disabled={disabled}
id="managed-coach-window"
name="defaultWindow"
>
<option value="last7days">Last 7 days</option>
<option value="last30days">Last 30 days</option>
<option value="last90days">Last 90 days</option>
<option value="allTime">All time</option>
</select>
</label>
</div>
<label className="flex items-center gap-3 text-sm">
<input
defaultChecked={preferences.showEvidenceByDefault === true}
disabled={disabled}
name="showEvidenceByDefault"
type="checkbox"
/>
Show evidence by default
</label>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Excluded metrics</legend>
{COACH_EXCLUDE_METRICS.map((metric) => (
<label
className="mr-4 inline-flex items-center gap-2 text-sm"
key={metric}
>
<input
defaultChecked={excluded.has(metric)}
disabled={disabled}
name={`exclude-${metric}`}
type="checkbox"
/>
{metric}
</label>
))}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Data clusters</legend>
<label className="mb-2 flex items-center gap-2 text-sm">
<input
defaultChecked={Array.isArray(preferences.dataClusters)}
disabled={disabled}
name="useCustomClusters"
type="checkbox"
/>
Use a custom cluster selection
</label>
{COACH_DATA_CLUSTERS.map((cluster) => (
<label
className="mr-4 inline-flex items-center gap-2 text-sm"
key={cluster}
>
<input
defaultChecked={dataClusters.has(cluster)}
disabled={disabled}
name={`cluster-${cluster}`}
type="checkbox"
/>
{cluster}
</label>
))}
</fieldset>
<SaveButton disabled={disabled} label={saveLabel} />
</form>
);
}
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<HTMLFormElement>) {
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[]) => (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{kind === "section" ? "Overview sections" : "Navigation tiles"}
</legend>
{items.map((item) => (
<div className="flex items-center gap-3 text-sm" key={item.id}>
<label className="flex flex-1 items-center gap-2">
<input
defaultChecked={item.visible}
disabled={disabled}
name={`${kind}-visible-${item.id}`}
type="checkbox"
/>
{item.id}
</label>
<label
className="flex items-center gap-2"
htmlFor={`${kind}-order-${item.id}`}
>
Order
<input
className="border-input bg-background w-16 rounded-md border px-2 py-1"
defaultValue={item.order}
disabled={disabled}
id={`${kind}-order-${item.id}`}
min="0"
name={`${kind}-order-${item.id}`}
type="number"
/>
</label>
</div>
))}
</fieldset>
);
return (
<form className="mt-4 space-y-5" onSubmit={submit}>
{renderItems("section", sections)}
{renderItems("tile", tiles)}
<SaveButton disabled={disabled} label={saveLabel} />
</form>
);
}
export function ManagedRecordSettingsForm({
family,
...props
}: SettingsFormProps & { family: ManagedSettingsFamily }) {
switch (family) {
case "profile":
return <ProfileSettingsForm {...props} />;
case "modules":
return <ModulesSettingsForm {...props} />;
case "notifications":
return <NotificationsSettingsForm {...props} />;
case "thresholds":
return <ThresholdsSettingsForm {...props} />;
case "coach":
return <CoachSettingsForm {...props} />;
case "insights":
return <InsightsSettingsForm {...props} />;
}
}
/**
* 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<ManagedRecordSettingsResponse>(
`/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<ManagedRecordSettingsResponse>(
`/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 <QueryErrorCard onRetry={() => void query.refetch()} />;
}
return (
<SettingsCard aria-busy={query.isLoading}>
<SettingsCardHeader
icon={Settings}
title={t(`settings.sections.${family}.title`)}
/>
{query.data ? (
<>
<ManagedRecordSettingsForm
disabled={save.isPending}
family={family}
onSave={(patch) => {
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 ? (
<p className="text-destructive mt-3 text-sm" role="alert">
{t("common.networkError")}
</p>
) : null}
</>
) : null}
</SettingsCard>
);
}
@@ -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 (
<SettingsCard aria-busy="true" role="status">
<SettingsCardHeader icon={Loader2} title={t("nav.loadingScreen")} />
</SettingsCard>
);
}
if (!inSharedRecord) return children;
const destination = classifySettingsDestination(section);
@@ -37,6 +61,18 @@ export function RecordSettingsSectionGate({
return <ManagedIntegrationStatus />;
}
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 <ManagedRecordSettingsSection family={family} />;
}
return (
<SettingsCard aria-labelledby="shared-record-settings-unavailable-title">
<SettingsCardHeader
+4
View File
@@ -12,6 +12,8 @@ export const recordSettingsKeys = {
["record-settings", recordId, "integrations"] as const,
profile: (recordId: string) =>
["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,
};
+198
View File
@@ -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<typeof profilePatchSchema>;
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<string, boolean> = {};
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;
}
+10
View File
@@ -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";
+14
View File
@@ -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");
}
}
@@ -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:");
});
});