mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat: open the anamnesis record surface to managers
POST /api/allergies and POST /api/family-history have resolved
requireRecordAuth("manage", "profile") since the level shipped, while the only
form in the product that posts to either sat behind a Settings destination
classified unavailable. The write was admitted, audited and owner-scoped, and
nobody could reach it — the one-ended change this repository keeps
rediscovering, with the permission shipped and the surface left as a follow-up.
The destination now carries a classification that means what the routes mean:
reachable by everybody they already admit, which is a guardian inside the
profile they administer and an adult delegate holding manage. The four places
that decided visibility on the guardian-only literal were each updated
deliberately, and the shell that refused every settings path in an adult share
now admits exactly the destinations classified as record content. Record
configuration — modules, thresholds, notification routing — stays with the
owner at every level.
The page inside a record is its own component holding the two managers whose
routes answer for a delegate. Mounting the personal section would have put
three more forms on screen that refuse on submit. The family-history card says
whose statement the row is, which the route comment could not.
Both routes carried a long paragraph arguing the write was deliberately
unreachable. It is gone rather than left to send the next reader back to close
what this opens.
The integration coverage moves from the undeclared-mode refusal to a manage
contract: a manage delegate and a guardian each write and the row lands under
the owner with the delegate named as actor, while read and write delegates get
the insufficient-access refusal. Nothing exercised either route at manage
before.
This commit is contained in:
1 parent
0cdfb97a45
commit
ef242f33bb
17 files changed
+550
-119
No files matched your search
@@ -6,6 +6,10 @@ import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
|
||||
const pathnameRef = { value: "/settings/security" };
|
||||
const activeRecordRef = { value: true };
|
||||
/** The record on screen. `managed` unless a case says otherwise. */
|
||||
const recordRef: { value: { kind: "managed" | "shared"; level: string } } = {
|
||||
value: { kind: "managed", level: "manage" },
|
||||
};
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathnameRef.value,
|
||||
@@ -23,8 +27,8 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
username: "managed-record",
|
||||
displayName: "Managed record",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
level: recordRef.value.level,
|
||||
recordKind: recordRef.value.kind,
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
},
|
||||
@@ -63,18 +67,36 @@ function renderSettings(pathname = "/settings/security") {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The destinations a record offers, by what kind of record it is.
|
||||
*
|
||||
* v1.37.0 — a managed profile offers guardian configuration AND the record
|
||||
* content a MANAGE holder may write, because a Guardian holds MANAGE. The
|
||||
* `manage-writable` kind is what carries the second half; before it existed
|
||||
* the anamnesis form had no page a Guardian could open, while the routes
|
||||
* behind it had admitted the write since the level shipped.
|
||||
*/
|
||||
const MANAGED_KINDS = new Set(["managed-guardian", "manage-writable"]);
|
||||
|
||||
describe("managed record navigation", () => {
|
||||
it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => {
|
||||
recordRef.value = { kind: "managed", level: "manage" };
|
||||
const html = renderSettings();
|
||||
const managedDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind === "managed-guardian",
|
||||
const managedDestinations = SETTINGS_SECTIONS.filter((section) =>
|
||||
MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
|
||||
);
|
||||
const unavailableDestinations = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind !== "managed-guardian",
|
||||
!MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind),
|
||||
);
|
||||
|
||||
// The partition is non-empty on both sides. A classification change that
|
||||
// emptied either would make one of the loops below prove nothing.
|
||||
expect(managedDestinations.length).toBeGreaterThan(1);
|
||||
expect(unavailableDestinations.length).toBeGreaterThan(1);
|
||||
// And the destination this release opened is really on the offered side.
|
||||
expect(managedDestinations.map((s) => s.slug)).toContain("anamnesis");
|
||||
|
||||
expect(html).toContain('data-active-record-id="managed-record"');
|
||||
expect(html).toContain('id="settings-section-security-title"');
|
||||
expect(html).not.toMatch(/href="[^"]*managed-record/);
|
||||
@@ -91,7 +113,46 @@ describe("managed record navigation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("offers an adult manager the record content and none of the configuration", () => {
|
||||
// The other half of the same classification, and the half that is easiest
|
||||
// to get wrong in the generous direction: a delegate manages somebody's
|
||||
// health record, not their account. Anamnese is offered; modules,
|
||||
// thresholds and notification routing are not, even though a Guardian
|
||||
// reaches all three.
|
||||
recordRef.value = { kind: "shared", level: "manage" };
|
||||
const html = renderSettings();
|
||||
|
||||
expect(html).toContain('href="/settings/anamnesis"');
|
||||
for (const guardianOnly of [
|
||||
"account",
|
||||
"modules",
|
||||
"notifications",
|
||||
"thresholds",
|
||||
"coach",
|
||||
]) {
|
||||
expect(html, guardianOnly).not.toContain(
|
||||
`href="/settings/${guardianOnly}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a WRITE delegate no settings destination at all", () => {
|
||||
// The refusal side. `manage-writable` is admitted by LEVEL, not by being
|
||||
// in a shared record: a WRITE grant reaches no Settings page, and a
|
||||
// destination it could see would be one the route below refuses.
|
||||
recordRef.value = { kind: "shared", level: "write" };
|
||||
const html = renderSettings();
|
||||
|
||||
expect(html).not.toContain('href="/settings/anamnesis"');
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(html, section.slug).not.toContain(
|
||||
`href="/settings/${section.slug}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the exit control present while administering a managed record", () => {
|
||||
recordRef.value = { kind: "managed", level: "manage" };
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SharedRecordBanner />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
isManageDelegateSettingsDestination,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
||||
@@ -74,6 +75,55 @@ describe("record settings contract", () => {
|
||||
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the anamnesis destination, which is the one piece of record
|
||||
* CONTENT under `/settings`.
|
||||
*
|
||||
* `POST /api/allergies` and `POST /api/family-history` have resolved
|
||||
* `requireRecordAuth("manage", "profile")` since MANAGE shipped, while the
|
||||
* only form that posts to either was classified unavailable — an admitted
|
||||
* write with no reachable caller. These three legs are the classification
|
||||
* half of closing that; the integration file drives the routes themselves.
|
||||
*/
|
||||
it("opens the anamnesis destination to everybody the routes already admit", () => {
|
||||
expect(classifySettingsDestination("anamnesis")).toEqual({
|
||||
kind: "manage-writable",
|
||||
guardianWritable: true,
|
||||
});
|
||||
// The Guardian half. Before this the destination existed and was
|
||||
// read-only for the one person the profile exists for.
|
||||
expect(isGuardianSettingsWriteAllowed("anamnesis")).toBe(true);
|
||||
// The adult-delegate half, and its boundary in the same breath: a MANAGE
|
||||
// grant opens the record's health background and nothing else under
|
||||
// Settings. Record CONFIGURATION — modules, thresholds, notification
|
||||
// routing — stays with the owner even though a Guardian may change it.
|
||||
expect(isManageDelegateSettingsDestination("anamnesis")).toBe(true);
|
||||
for (const guardianOnly of [
|
||||
"account",
|
||||
"modules",
|
||||
"notifications",
|
||||
"thresholds",
|
||||
"insights",
|
||||
"coach",
|
||||
"integrations",
|
||||
]) {
|
||||
expect(
|
||||
isManageDelegateSettingsDestination(guardianOnly),
|
||||
guardianOnly,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves exactly one destination on the manage-writable list", () => {
|
||||
// The list is short on purpose and a second entry is a consent decision,
|
||||
// not a classification tidy-up. Pinning the count means a destination
|
||||
// cannot join it as a side effect of somebody reclassifying a neighbour.
|
||||
const manageWritable = Object.entries(SETTINGS_DESTINATION_INVENTORY)
|
||||
.filter(([, value]) => value.kind === "manage-writable")
|
||||
.map(([slug]) => slug);
|
||||
expect(manageWritable).toEqual(["anamnesis"]);
|
||||
});
|
||||
|
||||
it("does not report a synthetic ledger row as a managed connection", () => {
|
||||
const connected = {
|
||||
withings: false,
|
||||
|
||||
@@ -67,39 +67,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
||||
|
||||
async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// v1.36.x — the GET above is delegable and this is not, which is the
|
||||
// opposite of where the classification landed and worth the paragraph.
|
||||
// v1.37.0 — MANAGE, and reachable.
|
||||
//
|
||||
// Nothing about the row changed: it is still a plain statement about the
|
||||
// record's own body, still the single most useful thing a caregiver could
|
||||
// contribute, and the argument for admitting it still holds. What it does
|
||||
// not have is a caller. The only place in the product that posts here is the
|
||||
// allergy manager in Settings → Anamnese, and `/settings/*` is not a
|
||||
// shared-record destination — the shell shows the "not part of what was
|
||||
// shared" panel there, so no delegate can reach the form at any level.
|
||||
// Recording an allergy for somebody whose record you manage is the case the
|
||||
// level exists for; on a managed profile it is the guardian's ordinary act.
|
||||
// Additive, audited, and the row belongs to the record owner.
|
||||
//
|
||||
// An admitted write with no reachable surface is the one-ended change this
|
||||
// repository keeps rediscovering (CLAUDE.md, "A two-ended change carries
|
||||
// both ends"): the permission ships, the consumer is the follow-up, and
|
||||
// nothing in the gate notices because every other check proves the other
|
||||
// end. The frozen list is built the other way round on purpose — its own
|
||||
// actor-surface note says the rest "arrive as their own diffs; naming them
|
||||
// before they exist would freeze a guess."
|
||||
// The surface is Settings → Anamnese inside the record
|
||||
// (`src/components/settings/record-anamnesis-section.tsx`, reached through
|
||||
// `record-settings-section-gate.tsx` for any destination classified
|
||||
// `manage-writable`). This arm carried a long paragraph through v1.36.x
|
||||
// arguing that the write was deliberately unreachable and waiting for that
|
||||
// surface; the surface exists now, so the paragraph is gone rather than left
|
||||
// to send the next reader back to close what it opens.
|
||||
//
|
||||
// So this arm waits for the surface that would exercise it, and the two
|
||||
// land together. Choosing that surface is design work, not a fix: allergies
|
||||
// and family history have exactly one home today, that home is a personal
|
||||
// account surface a switch rightly closes, and bolting a second copy onto a
|
||||
// shared page would split one concept across two places. Re-admitting is
|
||||
// one line here plus one entry in `sharing-surface-guard.test.ts` plus the
|
||||
// paragraph that argues it — which is exactly the reviewed diff that guard
|
||||
// exists to force.
|
||||
//
|
||||
// The half that was always the point is untouched: a caregiver can still
|
||||
// READ the allergy list inside the record.
|
||||
// v1.37.0 — MANAGE. Recording an allergy for somebody whose record you
|
||||
// manage is the case the level exists for; on a managed profile it is the
|
||||
// guardian's ordinary act. Additive, audited, and the row is the record's.
|
||||
// A READ or WRITE delegate reaches neither: the destination is not on their
|
||||
// Settings list, and this line refuses them with `sharing.access.denied`
|
||||
// regardless.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
|
||||
@@ -62,23 +62,18 @@ export const POST = apiHandler(
|
||||
);
|
||||
|
||||
async function postFamilyHistory(request: NextRequest): Promise<Response> {
|
||||
// v1.36.x — not a delegated write, though the GET above is delegable. The
|
||||
// reasoning is written out at the sibling arm in `api/allergies/route.ts`
|
||||
// and is the same here: the only surface that posts to either route is the
|
||||
// manager in Settings → Anamnese, and `/settings/*` is closed inside a
|
||||
// shared record, so no delegate can reach the form at any level. An
|
||||
// admitted write with no reachable caller is a permission frozen ahead of
|
||||
// the surface that would exercise it.
|
||||
// v1.37.0 — MANAGE, and reachable. Third-party data by design, admitted on
|
||||
// the same reasoning the read arm was: it is the record's own health
|
||||
// background, and the surface is Settings → Anamnese inside the record
|
||||
// (`src/components/settings/record-anamnesis-section.tsx`).
|
||||
//
|
||||
// This arm carried an extra reason to wait. The row describes the record's
|
||||
// RELATIVES, so it is the one admitted write where third-party health
|
||||
// information is present by design — and frequently the delegate is that
|
||||
// relative, asserting a condition about themselves into somebody else's
|
||||
// record. The classification admitted it and named the discomfort. Landing
|
||||
// it together with the surface that offers it means the copy on that surface
|
||||
// can say whose statement the row is, which no route comment can.
|
||||
// v1.37.0 — MANAGE. Third-party data by design, admitted on the same
|
||||
// reasoning the read arm was: it is the record's own health background.
|
||||
// This arm carried an extra reason to wait, and that reason is now answered
|
||||
// where it can be. The row describes the record subject's RELATIVES, so it
|
||||
// is the one admitted write where third-party health information is present
|
||||
// by design — and frequently the delegate IS that relative, asserting a
|
||||
// condition about themselves into somebody else's record. No route comment
|
||||
// can say that to the person filling the form; the card above the form says
|
||||
// it (`recordSharing.anamnesis.relativeNote`).
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isManageDelegateSettingsDestination } from "@/lib/record-settings";
|
||||
import { isDestinationInSharedRecord } from "./nav-model";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
@@ -84,15 +85,35 @@ export function AuthShell({
|
||||
const {
|
||||
inSharedRecord,
|
||||
recordKind,
|
||||
level,
|
||||
sections,
|
||||
accessRefused,
|
||||
recordSessionPending,
|
||||
} = 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");
|
||||
// A shared record has a deliberately small Settings surface of its own, and
|
||||
// which routes belong to it depends on the record.
|
||||
//
|
||||
// A managed profile keeps the whole `/settings` family reaching its
|
||||
// record-aware gate: guardian configuration lives there and the gate refuses
|
||||
// per destination.
|
||||
//
|
||||
// v1.37.0 — an ordinary shared record at MANAGE reaches exactly the
|
||||
// destinations classified `manage-writable`, which today is Settings →
|
||||
// Anamnese and nothing else. That is not a widening of what a delegate may
|
||||
// do: `POST /api/allergies` and `POST /api/family-history` already resolve
|
||||
// `requireRecordAuth("manage", "profile")` and have done since the level
|
||||
// shipped. What was missing was any page from which to call them, and an
|
||||
// admitted write with no reachable caller is the half-wired state this
|
||||
// repository keeps rediscovering. Every other `/settings` path in an adult
|
||||
// share stays in the generic refusal branch, at every level.
|
||||
const settingsSlug = pathname.startsWith("/settings")
|
||||
? (pathname.split("/")[2] ?? "")
|
||||
: null;
|
||||
const isRecordSettingsPath =
|
||||
settingsSlug !== null &&
|
||||
(recordKind === "managed" ||
|
||||
(level === "manage" &&
|
||||
isManageDelegateSettingsDestination(settingsSlug)));
|
||||
// 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
|
||||
@@ -101,7 +122,7 @@ export function AuthShell({
|
||||
const outsideSharedRecord =
|
||||
inSharedRecord &&
|
||||
!isDestinationInSharedRecord(pathname, sections) &&
|
||||
!isManagedRecordSettingsPath;
|
||||
!isRecordSettingsPath;
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldAlert, Users } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { AllergyManager } from "@/components/records/allergy-manager";
|
||||
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.37.0 — Settings → Anamnese, inside somebody else's record.
|
||||
*
|
||||
* ## Why a second component instead of the personal one
|
||||
*
|
||||
* `AnamnesisSection` renders five managers, and only two of them belong to the
|
||||
* record. The other three write through `/api/coach/about-me` and
|
||||
* `/api/anamnesis/facts`, both of which resolve a bare `requireAuth()` and
|
||||
* therefore refuse outright under a switch. Mounting the personal section here
|
||||
* would put three forms on screen that answer 403 on submit — the precise
|
||||
* failure `useRecordCapabilities` was written to end ("a control that appeared
|
||||
* before its route answered"). So this surface is the intersection of the
|
||||
* section and what the record routes admit, and it is a separate component so
|
||||
* that intersection is visible rather than expressed as four conditionals
|
||||
* inside the personal one.
|
||||
*
|
||||
* ## What is here
|
||||
*
|
||||
* Allergies and family history. Both post to routes that resolve
|
||||
* `requireRecordAuth("manage", "profile")`, so both land under the record
|
||||
* owner with the delegate named as actor, and a READ or WRITE delegate is
|
||||
* refused by the route as well as never reaching this page.
|
||||
*
|
||||
* Family history is the one admitted write where third-party health
|
||||
* information is present by design — the row describes the record subject's
|
||||
* relatives, and often enough the delegate IS that relative, asserting a
|
||||
* condition about themselves into somebody else's record. The route's own
|
||||
* comment named that discomfort and left it for the surface to say out loud,
|
||||
* because no route comment can. The sentence under the card is where it gets
|
||||
* said.
|
||||
*
|
||||
* The cache is not a concern here and the reason is worth naming: switching
|
||||
* records clears the whole query client, so the allergy and family-history
|
||||
* lists cannot carry one record's rows into another's view.
|
||||
*/
|
||||
export function RecordAnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="record-anamnesis-section">
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldAlert}
|
||||
title={t("records.allergies.cardTitle")}
|
||||
description={t("records.allergies.cardDescription")}
|
||||
/>
|
||||
<AllergyManager />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("records.family.cardTitle")}
|
||||
description={t("records.family.cardDescription")}
|
||||
/>
|
||||
<FamilyHistoryManager />
|
||||
<p
|
||||
data-slot="record-anamnesis-relative-note"
|
||||
className="text-foreground text-sm"
|
||||
>
|
||||
{t("recordSharing.anamnesis.relativeNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
|
||||
@@ -35,7 +36,7 @@ export function RecordSettingsSectionGate({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { isLoading } = useAuth();
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
const { inSharedRecord, recordKind, level } = useRecordCapabilities();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// A shared session must never begin by mounting the actor's Settings
|
||||
@@ -53,6 +54,21 @@ export function RecordSettingsSectionGate({
|
||||
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.
|
||||
if (destination.kind === "manage-writable" && level === "manage") {
|
||||
return <RecordAnamnesisSection />;
|
||||
}
|
||||
|
||||
if (
|
||||
recordKind === "managed" &&
|
||||
destination.kind === "managed-guardian" &&
|
||||
|
||||
@@ -439,19 +439,32 @@ export function SettingsShell({
|
||||
modules?.[section.moduleGate] !== false,
|
||||
);
|
||||
|
||||
// The server has already resolved which record is active. In a managed
|
||||
// profile, only guardian settings are a navigation destination; the section
|
||||
// gate remains responsible for refusing any direct URL that is not here.
|
||||
// The server has already resolved which record is active, and which
|
||||
// destinations that record offers depends on what kind of record it is. The
|
||||
// section gate remains responsible for refusing any direct URL that is not
|
||||
// on the list; this filter only stops the nav from offering one.
|
||||
//
|
||||
// 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.
|
||||
const visibleSections =
|
||||
activeRecord?.recordKind === "managed"
|
||||
? allVisibleSections.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind ===
|
||||
"managed-guardian",
|
||||
)
|
||||
: allVisibleSections;
|
||||
activeRecord === null
|
||||
? allVisibleSections
|
||||
: allVisibleSections.filter((section) => {
|
||||
const kind = classifySettingsDestination(section.slug).kind;
|
||||
if (activeRecord.recordKind === "managed") {
|
||||
return kind === "managed-guardian" || kind === "manage-writable";
|
||||
}
|
||||
return kind === "manage-writable" && activeRecord.level === "manage";
|
||||
});
|
||||
const activeSection = allVisibleSections.find(
|
||||
(section) => section.slug === activeSlug,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
export type SettingsDestinationKind =
|
||||
"personal" | "managed-guardian" | "adult-shared-unavailable" | "unavailable";
|
||||
| "personal"
|
||||
| "managed-guardian"
|
||||
| "manage-writable"
|
||||
| "adult-shared-unavailable"
|
||||
| "unavailable";
|
||||
|
||||
export interface SettingsDestinationClassification {
|
||||
kind: SettingsDestinationKind;
|
||||
@@ -23,6 +27,32 @@ const MANAGED_GUARDIAN_STATUS: SettingsDestinationClassification = {
|
||||
guardianWritable: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.37.0 — reachable by everybody the record routes behind it already admit.
|
||||
*
|
||||
* The other four kinds each answer for one population. `MANAGED_GUARDIAN` is
|
||||
* guardian-only; `ADULT_SHARED_UNAVAILABLE` is closed to adult shares and open
|
||||
* to nobody else; neither can express "the same set the route admits", which
|
||||
* for the anamnesis surface is a Guardian on a managed profile AND an adult
|
||||
* delegate holding MANAGE.
|
||||
*
|
||||
* That gap had a consequence rather than being a taxonomy nicety. `POST
|
||||
* /api/allergies` and `POST /api/family-history` both resolve
|
||||
* `requireRecordAuth("manage", "profile")` — the write is admitted, audited
|
||||
* and owner-scoped — while the only form in the product that posts to either
|
||||
* lives under Settings → Anamnese, which was classified unavailable. An
|
||||
* admitted write with no reachable caller is the one-ended change this
|
||||
* repository keeps rediscovering: the permission ships, the surface is "the
|
||||
* follow-up", and every other check proves the other end.
|
||||
*
|
||||
* `guardianWritable` is true because a Guardian writes here too; the kind is
|
||||
* what says the destination is not guardian-ONLY.
|
||||
*/
|
||||
const MANAGE_WRITABLE: SettingsDestinationClassification = {
|
||||
kind: "manage-writable",
|
||||
guardianWritable: true,
|
||||
};
|
||||
|
||||
const ADULT_SHARED_UNAVAILABLE: SettingsDestinationClassification = {
|
||||
kind: "adult-shared-unavailable",
|
||||
guardianWritable: false,
|
||||
@@ -55,7 +85,7 @@ export const SETTINGS_DESTINATION_INVENTORY = {
|
||||
labs: ADULT_SHARED_UNAVAILABLE,
|
||||
illness: ADULT_SHARED_UNAVAILABLE,
|
||||
environment: UNAVAILABLE,
|
||||
anamnesis: ADULT_SHARED_UNAVAILABLE,
|
||||
anamnesis: MANAGE_WRITABLE,
|
||||
score: ADULT_SHARED_UNAVAILABLE,
|
||||
vorsorge: ADULT_SHARED_UNAVAILABLE,
|
||||
thresholds: MANAGED_GUARDIAN,
|
||||
@@ -83,10 +113,36 @@ export function classifySettingsDestination(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* May a Guardian change this destination inside the profile they administer.
|
||||
*
|
||||
* Two kinds qualify, and the second is not a widening of the first: a
|
||||
* `manage-writable` destination is one the record routes already admit at
|
||||
* MANAGE, and a Guardian holds MANAGE by construction. Naming only
|
||||
* `managed-guardian` here would have left the anamnesis destination visible
|
||||
* and read-only for the one person the record exists for.
|
||||
*/
|
||||
export function isGuardianSettingsWriteAllowed(destination: string): boolean {
|
||||
const classification = classifySettingsDestination(destination);
|
||||
return (
|
||||
classification.kind === "managed-guardian" &&
|
||||
(classification.kind === "managed-guardian" ||
|
||||
classification.kind === "manage-writable") &&
|
||||
classification.guardianWritable
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this destination part of the record surface an adult MANAGE grant opens.
|
||||
*
|
||||
* Deliberately narrower than {@link isGuardianSettingsWriteAllowed}: a
|
||||
* Guardian administers a profile and reaches the record's configuration —
|
||||
* modules, thresholds, notification routing — while an adult delegate reaches
|
||||
* only the health record itself. `manage-writable` is the one kind that is
|
||||
* record content rather than record configuration, so it is the only kind an
|
||||
* ordinary shared record opens under `/settings`.
|
||||
*/
|
||||
export function isManageDelegateSettingsDestination(
|
||||
destination: string,
|
||||
): boolean {
|
||||
return classifySettingsDestination(destination).kind === "manage-writable";
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export { resolveManagedIntegrationState } from "./integrations";
|
||||
export {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
isManageDelegateSettingsDestination,
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
type SettingsDestinationClassification,
|
||||
type SettingsDestinationKind,
|
||||
|
||||
Reference in new issue
Block a user