Files
HealthLog/src/components/settings/record-settings-section-gate.tsx
T
Marc-André Bombeck 6ae0cc993f fix: close four small gaps around the record surface
The privacy dashboard re-implemented the audit-retention resolver with its own
default and its own floor. Two implementations of one disclosure, quoted to the
same person on two pages and by the purge job; it uses the shared resolver now.

The record-content branch in the settings gate matched a classification rather
than a page, so a second manage-writable destination would have rendered the
allergy and family-history managers under its own heading without anybody
choosing that. It resolves through a slug map, and the contract test fails if
the map and the classification disagree in either direction — a destination
cannot join the kind without somebody deciding what it renders.

The managed-profile nav filter assumed a guardian grant is always manage. It is
today, which is why the check was easy to leave out; an entry below that level
would have listed destinations the gate refuses.

Interpolation used a replacement string, and String.replace reads $&, $`, $',
$1 and $$ out of one. A display name containing $' rewrote the sentence around
it — and display names land in exactly the sentences that say who did what in
somebody's health record. A replacer function has no $ grammar at all, which
removes the class rather than escaping the members of it somebody thought of.
2026-08-07 01:00:02 +02:00

130 lines
5.0 KiB
TypeScript

"use client";
import type { ReactNode } from "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 { RecordAnamnesisSection } from "@/components/settings/record-anamnesis-section";
import { SettingsCard } from "@/components/settings/settings-card";
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
/**
* The record-CONTENT destinations, and what each one renders inside a record.
*
* Keyed by slug so the branch below asks which page it is on rather than which
* category the page belongs to. Every `manage-writable` slug must have an
* entry and nothing else may: the contract test holds both directions, which
* is what stops a new destination inheriting this one's forms.
*/
export const RECORD_CONTENT_SECTIONS: Partial<
Record<SettingsSectionSlug, () => ReactNode>
> = {
anamnesis: RecordAnamnesisSection,
};
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
* status projection; all other direct links resolve to a local refusal state.
*/
export function RecordSettingsSectionGate({
section,
children,
}: {
section: SettingsSectionSlug;
children: ReactNode;
}) {
const { isLoading } = useAuth();
const { inSharedRecord, recordKind, level } = 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);
// v1.37.0 — the one destination that is record CONTENT rather than record
// configuration, and therefore the one every MANAGE holder reaches: a
// Guardian inside the profile they administer, and an adult delegate whose
// grant is MANAGE. Both post to routes that already resolve
// `requireRecordAuth("manage", "profile")`; before this the forms had no
// reachable page and the permission had no caller.
//
// Checked before the managed branches so a Guardian gets the same record
// surface an adult manager does, rather than the guardian configuration
// panel, which has no family for it.
//
// Resolved through a slug map rather than by matching the kind, and that is
// not belt-and-braces. `manage-writable` is a classification, not a
// component: a second destination joining it — a future record-content page
// — would otherwise render the allergy and family-history managers under its
// own heading, silently, because the branch matched a category where a page
// was meant. `RECORD_CONTENT_SECTIONS` says which page each slug is, a slug
// with no entry falls through to the refusal panel below, and
// `record-settings-contract.test.ts` fails if the map and the classification
// ever disagree — so a destination cannot join the kind without somebody
// deciding what it renders.
const RecordContent =
level === "manage" && destination.kind === "manage-writable"
? RECORD_CONTENT_SECTIONS[section]
: undefined;
if (RecordContent) return <RecordContent />;
if (
recordKind === "managed" &&
destination.kind === "managed-guardian" &&
section === "integrations"
) {
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
icon={CircleSlash}
title={t("settings.sharedRecord.unavailableTitle")}
titleId="shared-record-settings-unavailable-title"
description={t("settings.sharedRecord.unavailableDescription")}
/>
</SettingsCard>
);
}