refactor(settings): share the record section predicate

Move the rule that decides which Settings sections a shared record
lists out of the settings shell and into the record-settings
classification module, next to the inventory it reads. The shell
filters through it unchanged; the navigation reads the same answer
in the next change, so the two cannot disagree about whether a record
has Settings pages and where they start.

Refs #939
This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:02:56 +02:00
1 parent 75760b606a
commit f5fc713783
2 files changed
+82 -24

No files matched your search

+8 -23
View File
@@ -49,7 +49,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
import { classifySettingsDestination } from "@/lib/record-settings";
import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
@@ -448,31 +448,16 @@ export function SettingsShell({
// Keep `allVisibleSections` for the active heading so a direct unavailable
// link identifies itself before the gate explains why it cannot open.
//
// * A managed profile shows its guardian configuration AND the record
// content a MANAGE holder may write — the guardian holds MANAGE, so the
// second set is theirs as well.
// * An ordinary shared record at MANAGE shows only the record content. A
// delegate manages somebody's health record, not their account: modules,
// thresholds and notification routing stay with the owner.
// * Every other shared record shows nothing here, because it reaches no
// Settings destination at all.
// Which destinations a record lists is answered by
// `isSettingsDestinationListedForRecord`, the same predicate the app
// navigation reads to decide whether to offer a Settings entry at all and
// where it lands. Its docblock carries the per-kind rules.
const visibleSections =
activeRecord === null
? allVisibleSections
: allVisibleSections.filter((section) => {
const kind = classifySettingsDestination(section.slug).kind;
// Every destination on either list needs MANAGE, so the level is
// checked once for both record kinds rather than only for the adult
// one. A guardian grant is always MANAGE today, which is exactly why
// the check was easy to leave out — and a managed entry that ever
// arrived below it would have listed destinations the section gate
// refuses, sending somebody to a page that explains it cannot open.
if (activeRecord.level !== "manage") return false;
if (activeRecord.recordKind === "managed") {
return kind === "managed-guardian" || kind === "manage-writable";
}
return kind === "manage-writable";
});
: allVisibleSections.filter((section) =>
isSettingsDestinationListedForRecord(section.slug, activeRecord),
);
const activeSection = allVisibleSections.find(
(section) => section.slug === activeSlug,
);
+74 -1
View File
@@ -1,4 +1,11 @@
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
import {
SETTINGS_SECTION_SLUGS,
type SettingsSectionSlug,
} from "@/components/settings/section-slugs";
import type {
AccountAccessLevel,
AccountRecordKind,
} from "@/lib/sharing/account-access-view";
export type SettingsDestinationKind =
| "personal"
@@ -138,3 +145,69 @@ export function isManageDelegateSettingsDestination(
): boolean {
return classifySettingsDestination(destination).kind === "manage-writable";
}
/**
* The record a Settings listing is drawn for: the server-resolved kind of the
* record on screen and the level of the grant that opened it. `level` is null
* in one's own record and in a context the client could not prove.
*/
export interface SettingsRecordContext {
recordKind: AccountRecordKind;
level: AccountAccessLevel | null;
}
/**
* Does the Settings shell list this destination inside a shared record.
*
* The one answer both the shell's section list and the app navigation read,
* so the navigation cannot offer a Settings entry the shell has nothing
* behind, nor withhold one the shell would list.
*
* * A managed profile at MANAGE lists its guardian configuration and the
* record content a MANAGE holder may write. The guardian holds MANAGE, so
* the second set is theirs as well.
* * An ordinary shared record at MANAGE lists only the record content. A
* delegate manages somebody's health record, not their account: modules,
* thresholds and notification routing stay with the owner.
* * Every other context lists nothing.
*
* Every destination on either list needs MANAGE, so the level is checked once
* for both record kinds. A guardian grant is always MANAGE today, which is
* exactly why the check was easy to leave out, and a managed entry that ever
* arrived below it would have listed destinations the section gate refuses.
*
* Paint only. The section gate still refuses any direct URL on its own.
*/
export function isSettingsDestinationListedForRecord(
destination: string,
record: SettingsRecordContext,
): boolean {
if (record.level !== "manage") return false;
const kind = classifySettingsDestination(destination).kind;
if (record.recordKind === "managed") {
return kind === "managed-guardian" || kind === "manage-writable";
}
if (record.recordKind === "shared") return kind === "manage-writable";
return false;
}
/**
* The Settings page a navigation entry opens inside a shared record, or null
* when the shell would list nothing there and no entry should be offered.
*
* `order` is the order the shell lists its sections in. The navigation passes
* the slug registry, whose order differs from the shell's in general; the
* parity test in `nav-model-shared-record.test.ts` holds the first answer
* equal to the first section the shell actually lists, for every record kind,
* so a reorder on either side fails there rather than landing somebody on a
* page the shell does not open with.
*/
export function recordSettingsLandingDestination(
record: SettingsRecordContext,
order: readonly SettingsSectionSlug[] = SETTINGS_SECTION_SLUGS,
): SettingsSectionSlug | null {
return (
order.find((slug) => isSettingsDestinationListedForRecord(slug, record)) ??
null
);
}