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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user