feat: gate a switched browser on the record's modules, not the actor's

Turning a module off for a profile you look after wrote a column nothing
on screen was reading. GET /api/auth/me published the ACTOR's module map
while switched, and the navigation, the dashboard tiles and the insights
sections all gate on it — so a guardian who turned Cycle off for the child
kept seeing a Cycle destination inside the child's record, and a guardian
who tracks it saw one there whether or not the child did (#939).

The map and the resolved cycle flag now answer for the record the session
is inside. They are not display preferences: every surface they gate shows
the record's data, so the record is what decides. Locale, theme and layout
stay the actor's. The record is taken from the grant-validated active
entry rather than the session's own selector, so a revoked switch cannot
publish somebody's configuration, and with no switch the payload is what
it was.

Cycle also gets a row on the guardian's Modules surface, which it never
had. It is a delegated module, so its state is the record's own cycle
profile and modulePreferences.cycle is refused — which left a guardian no
way to turn it off for a record whose sex says it should be on. The family
names the real column instead of a blob entry the gate ignores.

Refs #939
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 00:02:03 +02:00
1 parent f521b03c35
commit fb1cb4e3ed
9 files changed
+556 -46

No files matched your search

@@ -20,9 +20,27 @@ vi.mock("next/navigation", () => ({
const mockModulesRef: { value: Record<string, boolean> | undefined } = {
value: undefined,
};
// v1.37.0 — the shell also filters by which record the browser is inside.
// Unlike the module gate above, that filter is NOT hydration-deferred: the
// server has already resolved which record is active, so it applies on the SSR
// pass and is assertable from a static render.
const mockActiveRecordRef: {
value: { recordKind: "managed" | "shared"; level: string } | null;
} = { value: null };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER", modules: mockModulesRef.value },
user: {
id: "u1",
role: "USER",
modules: mockModulesRef.value,
accountAccess: mockActiveRecordRef.value
? {
accounts: [mockActiveRecordRef.value],
active: mockActiveRecordRef.value,
canSwitch: true,
}
: null,
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
@@ -42,9 +60,11 @@ function renderShell(props: {
pathname?: string;
locale?: "en" | "de";
modules?: Record<string, boolean>;
activeRecord?: { recordKind: "managed" | "shared"; level: string } | null;
}) {
mockPathnameRef.value = props.pathname ?? "/settings/account";
mockModulesRef.value = props.modules;
mockActiveRecordRef.value = props.activeRecord ?? null;
return renderToStaticMarkup(
<I18nProvider initialLocale={props.locale ?? "en"}>
<SettingsShell active={props.active}>
@@ -327,6 +347,39 @@ describe("<SettingsShell>", () => {
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
});
describe("inside somebody else's record", () => {
/**
* #939 — a guardian could turn a module off for the profile they look
* after and had to find the page by URL to do it. The section is
* classified `managed-guardian`, which is what the shell offers a managed
* record; asserting it here is what stops a future narrowing of the filter
* from silently taking the destination away again.
*/
it("offers the Modules destination to a guardian on a managed record", () => {
const html = renderShell({
active: "account",
activeRecord: { recordKind: "managed", level: "manage" },
});
expect(html).toContain('href="/settings/modules"');
// Its own configuration is not the record's, and stays away.
expect(html).not.toContain('href="/settings/security"');
});
it("does not offer it to an adult delegate holding MANAGE", () => {
// A delegate manages somebody's health record, not their account:
// modules, thresholds and notification routing stay with the owner.
// Integrations remains status-only on a managed record, and unreachable
// here — both are `managed-guardian`, which an adult record never opens.
const html = renderShell({
active: "account",
activeRecord: { recordKind: "shared", level: "manage" },
});
expect(html).not.toContain('href="/settings/modules"');
expect(html).not.toContain('href="/settings/integrations"');
expect(html).toContain('href="/settings/anamnesis"');
});
});
it("falls back to `account` when the pathname doesn't match a known slug", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
@@ -505,6 +505,7 @@ function ModulesSettingsForm({
modulePreferences: Object.fromEntries(
entries.map(([key]) => [key, form.get(key) === "on"]),
),
cycleTrackingEnabled: form.get("cycleTrackingEnabled") === "on",
});
}
@@ -523,6 +524,17 @@ function ModulesSettingsForm({
disabled={disabled}
/>
))}
{/* v1.38.14 (#939) — Cycle sits below the rest because it is not one of
them: it is a DELEGATED module, so its state lives in the record's own
cycle profile rather than in the module blob, and it had no row here
at all. A guardian could not turn it off for a record whose sex says
it should be on, which is the case the issue was opened about. */}
<ManagedSwitchRow
name="cycleTrackingEnabled"
label={t(MODULE_REGISTRY.cycle.labelKey)}
defaultChecked={settings.cycleTrackingEnabled === true}
disabled={disabled}
/>
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);