From cb151003417f9a1fae221f4c182b418043bf1d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:13:54 +0200 Subject: [PATCH 01/36] test: cover scoped record navigation --- .../shared-record-navigation.test.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/__tests__/shared-record-navigation.test.tsx diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx new file mode 100644 index 000000000..df014f4f5 --- /dev/null +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + SHARED_RECORD_DOMAIN_ROUTE_FAMILIES, + resolveSharedRecordNavigation, +} from "@/lib/navigation/shared-record"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; +import { + isDestinationInSharedRecord, + visibleNavDestinations, +} from "@/components/layout/nav-model"; + +describe("shared-record navigation", () => { + it("keeps every granted route family in one closed eight-domain inventory", () => { + expect(Object.keys(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES)).toEqual( + SHARE_DOMAINS, + ); + + for (const domain of SHARE_DOMAINS) { + expect( + SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].length, + `${domain} needs at least one route family`, + ).toBeGreaterThan(0); + } + }); + + it("offers a scoped grant only its matching doorway", () => { + const navigation = resolveSharedRecordNavigation(["labs"]); + + expect(navigation.destinationHrefs).toEqual(["/labs"]); + expect(navigation.allowsPath("/labs/panel/7")).toBe(true); + expect(navigation.allowsPath("/medications")).toBe(false); + + expect( + visibleNavDestinations(undefined, true, true, ["labs"]).map( + (destination) => destination.href, + ), + ).toEqual(["/labs"]); + expect(isDestinationInSharedRecord("/labs/panel/7", ["labs"])).toBe(true); + expect(isDestinationInSharedRecord("/medications", ["labs"])).toBe(false); + }); + + it("keeps whole-record navigation distinct from an all-sections scope", () => { + const wholeRecord = resolveSharedRecordNavigation(null); + const allSections = resolveSharedRecordNavigation([...SHARE_DOMAINS]); + + expect(wholeRecord.allowsPath("/")).toBe(true); + expect(allSections.allowsPath("/")).toBe(false); + }); + + it("refuses unclassified direct URLs in presentation before pages start reads", () => { + const navigation = resolveSharedRecordNavigation(["documents"]); + + expect(navigation.allowsPath("/documents/record-1")).toBe(true); + expect(navigation.allowsPath("/settings/account")).toBe(false); + expect(navigation.allowsPath("/new-unclassified-route")).toBe(false); + }); +}); From afae57207cae63ce4ddd8e4b59dbdb7bb3effab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:13:59 +0200 Subject: [PATCH 02/36] feat: scope shared record navigation --- src/components/layout/auth-shell.tsx | 4 +- src/components/layout/bottom-nav.tsx | 15 ++-- src/components/layout/nav-model.ts | 28 +++++-- src/components/layout/sidebar-nav.tsx | 8 +- src/lib/navigation/shared-record.ts | 101 ++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 src/lib/navigation/shared-record.ts diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index ae67180d7..f9948a742 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -78,7 +78,7 @@ export function AuthShell({ // decides what to mount and the surfaces beneath it decide what to offer, // and the two disagreeing is the whole class of defect this hook exists to // close. - const { inSharedRecord, recordKind } = useRecordCapabilities(); + const { inSharedRecord, recordKind, sections } = 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. @@ -91,7 +91,7 @@ export function AuthShell({ // — every route behind these pages refuses on its own. const outsideSharedRecord = inSharedRecord && - !isDestinationInSharedRecord(pathname) && + !isDestinationInSharedRecord(pathname, sections) && !isManagedRecordSettingsPath; const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 384bab972..de0e12093 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -97,8 +97,12 @@ export function BottomNav() { // v1.36.0 — acting on somebody else's record. Same gate as the sidebar, from // the same resolved payload, so the two bars cannot disagree about what a // delegate is offered. - const sharedRecord = user?.accountAccess?.active != null; - const { canAdd, canManage } = useRecordCapabilities(); + const { + inSharedRecord: sharedRecord, + sections, + canAdd, + canManage, + } = useRecordCapabilities(); const canCapture = canAdd || canManage; // v1.17.1 (F-1) — the More hub is the model-computed hub: every visible @@ -111,8 +115,9 @@ export function BottomNav() { modules: user?.modules, mounted, sharedRecord, + sections, }), - [user?.modules, mounted, sharedRecord], + [user?.modules, mounted, sharedRecord, sections], ); // v1.18.0 — drop a module-gated primary slot (Insights) when the account @@ -127,11 +132,11 @@ export function BottomNav() { // v1.36.0 — the fixed slot asks the shared destination model // whether sharing covers it, rather than carrying its own answer: // Insights is an AI surface and drops out under a switch. - (!sharedRecord || isDestinationInSharedRecord(item.href)) && + (!sharedRecord || isDestinationInSharedRecord(item.href, sections)) && (!item.requiresModule || (mounted && user?.modules?.[item.requiresModule] !== false)), ), - [user?.modules, mounted, sharedRecord], + [user?.modules, mounted, sharedRecord, sections], ); function isActiveLink(href: string) { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index f8e8ab0b7..4e925f7f2 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -18,6 +18,8 @@ import { } from "lucide-react"; import type { ModuleKey } from "@/lib/modules/registry"; +import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record"; +import type { ShareDomain } from "@/lib/sharing/scope"; /** * v1.17.1 — the single navigation information-model. @@ -305,8 +307,15 @@ function isNavDestinationVisible( modules: ModuleVisibilityMap | undefined, mounted = true, sharedRecord = false, + sections: readonly ShareDomain[] | null = null, ): boolean { - if (sharedRecord && d.sharedRecord !== true) return false; + if ( + sharedRecord && + (d.sharedRecord !== true || + !isSharedRecordPathPresentable(d.href, sections)) + ) { + return false; + } if (!d.requiresModule) return true; if (!mounted) return false; return modules?.[d.requiresModule] !== false; @@ -325,12 +334,11 @@ function isNavDestinationVisible( * `false`: a surface nobody has classified is not one to open inside somebody * else's record. */ -export function isDestinationInSharedRecord(pathname: string): boolean { - return NAV_DESTINATIONS.some((d) => { - if (!d.sharedRecord) return false; - if (d.href === "/") return pathname === "/"; - return pathname === d.href || pathname.startsWith(`${d.href}/`); - }); +export function isDestinationInSharedRecord( + pathname: string, + sections: readonly ShareDomain[] | null = null, +): boolean { + return isSharedRecordPathPresentable(pathname, sections); } /** @@ -348,9 +356,10 @@ export function visibleNavDestinations( modules: ModuleVisibilityMap | undefined, mounted = true, sharedRecord = false, + sections: readonly ShareDomain[] | null = null, ): NavDestination[] { return NAV_DESTINATIONS.filter((d) => - isNavDestinationVisible(d, modules, mounted, sharedRecord), + isNavDestinationVisible(d, modules, mounted, sharedRecord, sections), ); } @@ -389,11 +398,14 @@ export function mobileMoreHubDestinations(opts: { mounted?: boolean; /** v1.36.0 — acting on somebody else's record. Defaults to own. */ sharedRecord?: boolean; + /** Server-resolved scope for the active shared record. */ + sections?: readonly ShareDomain[] | null; }): MobileMoreHubEntry[] { return visibleNavDestinations( opts.modules, opts.mounted ?? true, opts.sharedRecord ?? false, + opts.sections ?? null, ) .filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href)) .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index bb86b5538..3d8588db9 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -29,6 +29,7 @@ import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useMounted } from "@/hooks/use-mounted"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -306,10 +307,11 @@ export function SidebarNav() { // v1.36.0 — while this browser is acting on somebody else's record, the nav // shows only what sharing covers. The server refuses the rest regardless; // dropping the entries spares a delegate a click that ends in a 403. - const sharedRecord = user?.accountAccess?.active != null; + const { inSharedRecord: sharedRecord, sections } = useRecordCapabilities(); const visibleNavItems = useMemo( - () => visibleNavDestinations(user?.modules, mounted, sharedRecord), - [user?.modules, mounted, sharedRecord], + () => + visibleNavDestinations(user?.modules, mounted, sharedRecord, sections), + [user?.modules, mounted, sharedRecord, sections], ); // Collapsed = the user's stored choice, or — with no stored choice — a // viewport default: tablet widths (md–lg, e.g. an iPad held upright) diff --git a/src/lib/navigation/shared-record.ts b/src/lib/navigation/shared-record.ts new file mode 100644 index 000000000..23f04767d --- /dev/null +++ b/src/lib/navigation/shared-record.ts @@ -0,0 +1,101 @@ +import { + ENTIRE_RECORD, + SHARE_DOMAINS, + type ShareDomain, + type ShareScope, +} from "@/lib/sharing/scope"; + +/** + * The browser route families a scoped grant can present. + * + * This is deliberately a presentation inventory, not an authorization table. + * `sections` arrives here as the server-resolved value from `accountAccess`; + * the request handler still evaluates the active grant for every read and + * mutation. Keeping the browser's route families closed lets a new domain + * fail closed in chrome until it has been intentionally classified. + */ +export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = { + measurements: ["/measurements"], + medications: ["/medications"], + labs: ["/labs"], + profile: ["/settings/anamnesis"], + illness: ["/illness"], + mind: ["/mood", "/mental-wellbeing"], + cycle: ["/cycle"], + documents: ["/documents"], +} as const satisfies Record; + +/** + * Routes whose reads span multiple domains. A selected-domain grant never + * opens these, even when it happens to name every currently known domain. + */ +const WHOLE_RECORD_ROUTE_FAMILIES = ["/", "/achievements"] as const; + +interface RouteFamily { + href: string; + scope: ShareScope; +} + +const SHARED_RECORD_ROUTE_FAMILIES: readonly RouteFamily[] = [ + ...WHOLE_RECORD_ROUTE_FAMILIES.map((href) => ({ + href, + scope: ENTIRE_RECORD, + })), + ...SHARE_DOMAINS.flatMap((domain) => + SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].map((href) => ({ + href, + scope: domain, + })), + ), +]; + +function matchesRouteFamily(pathname: string, href: string): boolean { + return href === "/" + ? pathname === href + : pathname === href || pathname.startsWith(`${href}/`); +} + +function includesScope( + sections: readonly ShareDomain[] | null, + scope: ShareScope, +): boolean { + if (sections === null) return true; + return scope !== ENTIRE_RECORD && sections.includes(scope); +} + +/** + * Server-resolved navigation paint for the active shared record. + * + * The return value never makes an authorization decision. It only prevents a + * known-unavailable page from mounting its reads after the server has already + * said which record sections the browser is inside. + */ +export function resolveSharedRecordNavigation( + sections: readonly ShareDomain[] | null, +) { + const routeFamilies = SHARED_RECORD_ROUTE_FAMILIES.filter((family) => + includesScope(sections, family.scope), + ); + + return { + destinationHrefs: routeFamilies.map((family) => family.href), + allowsPath(pathname: string): boolean { + return routeFamilies.some((family) => + matchesRouteFamily(pathname, family.href), + ); + }, + }; +} + +/** + * Does the server-resolved scope make this browser route presentable? + * + * This helper is intentionally named for presentation. It must never be used + * by a route handler or to choose an API request's target record. + */ +export function isSharedRecordPathPresentable( + pathname: string, + sections: readonly ShareDomain[] | null, +): boolean { + return resolveSharedRecordNavigation(sections).allowsPath(pathname); +} From 908897cc6b48cae6b9818a5c8c3dc6b3530cb327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:17:58 +0200 Subject: [PATCH 03/36] test: cover shared record modes --- e2e/v137-sharing-managed-profiles.spec.ts | 14 ++++++++ .../shared-record-navigation.test.tsx | 32 +++++++++++++++++++ .../__tests__/looking-after-card.test.tsx | 23 +++++++++++++ .../__tests__/shared-record-chrome.test.tsx | 25 +++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index a97bc38ae..8546db512 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -8,4 +8,18 @@ test.describe("sharing release journey anchors", () => { expect(journey.contract).toBe("inventory-only"); }); } + + test("adult levels and lifecycle", () => { + const adultJourneyNames = RELEASE_JOURNEYS.filter((journey) => + [ + "adult-full-read-scoped-read-write-and-manage", + "manage-mutation-activity-and-fenced-settings", + ].includes(journey.name), + ).map((journey) => journey.name); + + expect(adultJourneyNames).toEqual([ + "adult-full-read-scoped-read-write-and-manage", + "manage-mutation-activity-and-fenced-settings", + ]); + }); }); diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index df014f4f5..baed20fed 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -9,6 +9,7 @@ import { isDestinationInSharedRecord, visibleNavDestinations, } from "@/components/layout/nav-model"; +import { resolveRecordPresentation } from "@/lib/navigation/record-presentation"; describe("shared-record navigation", () => { it("keeps every granted route family in one closed eight-domain inventory", () => { @@ -55,4 +56,35 @@ describe("shared-record navigation", () => { expect(navigation.allowsPath("/settings/account")).toBe(false); expect(navigation.allowsPath("/new-unclassified-route")).toBe(false); }); + + it("keeps server-resolved access and record kind distinct in chrome", () => { + expect( + resolveRecordPresentation({ + level: "read", + canWrite: false, + recordKind: "shared", + }), + ).toEqual({ access: "view", recordKind: "shared" }); + expect( + resolveRecordPresentation({ + level: "write", + canWrite: true, + recordKind: "shared", + }), + ).toEqual({ access: "view-and-add", recordKind: "shared" }); + expect( + resolveRecordPresentation({ + level: "manage", + canWrite: true, + recordKind: "shared", + }), + ).toEqual({ access: "manage", recordKind: "shared" }); + expect( + resolveRecordPresentation({ + level: "manage", + canWrite: true, + recordKind: "managed", + }), + ).toEqual({ access: "manage", recordKind: "managed" }); + }); }); diff --git a/src/components/dashboard/__tests__/looking-after-card.test.tsx b/src/components/dashboard/__tests__/looking-after-card.test.tsx index 9cfe7e1f2..196ca6620 100644 --- a/src/components/dashboard/__tests__/looking-after-card.test.tsx +++ b/src/components/dashboard/__tests__/looking-after-card.test.tsx @@ -40,6 +40,16 @@ const FATHER = { recordKind: "shared" as const, canWrite: true, }; +const MANAGED_PROFILE = { + accountId: "managed-profile", + username: "managed-profile", + displayName: "Managed profile", + access: "write" as const, + level: "manage" as const, + sections: null, + recordKind: "managed" as const, + canWrite: true, +}; const mockAccessRef: { value: AccountAccess } = { value: { accounts: [], active: null, canSwitch: false }, @@ -130,6 +140,19 @@ describe("", () => { expect(html).not.toContain("Can view and add"); }); + it("keeps MANAGE and managed profile labels separate", () => { + const html = render({ + accounts: [MANAGED_PROFILE], + active: null, + canSwitch: true, + }); + + expect(html).toContain('data-access-level="manage"'); + expect(html).toContain('data-record-kind="managed"'); + expect(html).toContain("Can read the record, add to it, and change"); + expect(html).toContain("Managed profile"); + }); + it("shows no health data, only a doorway", () => { const html = render({ accounts: [GRANDMOTHER], diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx index a6c8a9651..b2fbd65ca 100644 --- a/src/components/layout/__tests__/shared-record-chrome.test.tsx +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -134,6 +134,31 @@ describe("", () => { expect(html).not.toContain("read it, not change it"); }); + it("names MANAGE and a managed profile without reducing either to add access", () => { + const managedProfile = { + ...OWNER, + accountId: "managed-profile", + access: "write" as const, + level: "manage" as const, + recordKind: "managed" as const, + canWrite: true, + }; + const html = render( + { + accounts: [managedProfile], + active: managedProfile, + canSwitch: true, + }, + , + ); + + expect(html).toContain('data-access-level="manage"'); + expect(html).toContain('data-record-kind="managed"'); + expect(html).toContain("Managed profile"); + expect(html).toContain("change or remove what is in it"); + expect(html).not.toContain("add to it, but not change"); + }); + it("uses the warning register, not a subtle tint", () => { // Somebody who stops noticing the banner logs their own reading into // another person's record. The visual register is part of the contract. From ba3be7ba9b5ddb2c8516001ea0926c67c2256220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:18:05 +0200 Subject: [PATCH 04/36] feat: distinguish managed record access --- .../dashboard/looking-after-card.tsx | 90 ++++++++++--------- .../layout/account-switcher-menu.tsx | 73 ++++++++++----- .../layout/shared-record-banner.tsx | 36 +++++--- src/lib/navigation/record-presentation.ts | 30 +++++++ src/lib/navigation/shared-record.ts | 6 +- 5 files changed, 157 insertions(+), 78 deletions(-) create mode 100644 src/lib/navigation/record-presentation.ts diff --git a/src/components/dashboard/looking-after-card.tsx b/src/components/dashboard/looking-after-card.tsx index c95d6f94b..df251ef40 100644 --- a/src/components/dashboard/looking-after-card.tsx +++ b/src/components/dashboard/looking-after-card.tsx @@ -7,6 +7,10 @@ import { TileHeader } from "@/components/insights/tile-header"; import { useAccountSwitch } from "@/hooks/use-account-switch"; import { useAccountOnceMounted } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { + resolveRecordPresentation, + type RecordPresentation, +} from "@/lib/navigation/record-presentation"; import { accountLabel, type AccountAccessEntry, @@ -77,42 +81,48 @@ export function LookingAfterCard() { {t("recordSharing.lookingAfter.description")}

    - {accounts.map((entry) => ( -
  • - -
  • - ))} + {switchAccount.isPending && ( +
@@ -131,22 +141,22 @@ export function LookingAfterCard() { * clothes. */ function levelLabel( - entry: AccountAccessEntry, + presentation: RecordPresentation, t: (key: string) => string, ): string { - if (entry.level === "manage") { + if (presentation.access === "manage") { return t("recordSharing.row.canManage"); } - return entry.canWrite + return presentation.access === "view-and-add" ? t("recordSharing.lookingAfter.levelWrite") : t("recordSharing.lookingAfter.levelRead"); } function recordKindLabel( - entry: AccountAccessEntry, + presentation: RecordPresentation, t: (key: string) => string, ): string { - return entry.recordKind === "managed" + return presentation.recordKind === "managed" ? t("recordSharing.lookingAfter.kindManaged") : t("recordSharing.lookingAfter.kindShared"); } diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx index e132d385c..e3a3f5513 100644 --- a/src/components/layout/account-switcher-menu.tsx +++ b/src/components/layout/account-switcher-menu.tsx @@ -12,6 +12,7 @@ import { import { useAccountSwitch } from "@/hooks/use-account-switch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { resolveRecordPresentation } from "@/lib/navigation/record-presentation"; import { accountLabel } from "@/lib/sharing/account-access-view"; /** @@ -79,32 +80,56 @@ export function AccountSwitcherMenuItems() { ✓ )} - {access.accounts.map((account) => ( - switchAccount.mutate(account.accountId)} - > -
- {accountLabel(account)} - {/* Meta, so muted: what the grant lets this person do, in the - words the server resolved rather than a level code. */} - - {account.canWrite - ? t("recordSharing.switcher.canWrite") - : t("recordSharing.switcher.readOnly")} - -
- {activeId === account.accountId && ( - - )} -
- ))} + {access.accounts.map((account) => { + const presentation = resolveRecordPresentation(account); + + return ( + switchAccount.mutate(account.accountId)} + > +
+ {accountLabel(account)} + + {accessLabel(presentation.access, t)} + + + {recordKindLabel(presentation.recordKind, t)} + +
+ {activeId === account.accountId && ( + + )} +
+ ); + })} ); } + +function accessLabel( + access: ReturnType["access"], + t: ReturnType["t"], +): string { + if (access === "manage") return t("recordSharing.row.canManage"); + return access === "view-and-add" + ? t("recordSharing.switcher.canWrite") + : t("recordSharing.switcher.readOnly"); +} + +function recordKindLabel( + recordKind: ReturnType["recordKind"], + t: ReturnType["t"], +): string { + return recordKind === "managed" + ? t("recordSharing.lookingAfter.kindManaged") + : t("recordSharing.lookingAfter.kindShared"); +} diff --git a/src/components/layout/shared-record-banner.tsx b/src/components/layout/shared-record-banner.tsx index 63651d3ef..3d8744abb 100644 --- a/src/components/layout/shared-record-banner.tsx +++ b/src/components/layout/shared-record-banner.tsx @@ -5,6 +5,7 @@ import { Eye, Loader2, LogOut } from "lucide-react"; import { useAccountSwitch } from "@/hooks/use-account-switch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { resolveRecordPresentation } from "@/lib/navigation/record-presentation"; import { accountLabel } from "@/lib/sharing/account-access-view"; /** @@ -59,12 +60,15 @@ export function SharedRecordBanner() { if (!active) return null; const name = accountLabel(active); + const presentation = resolveRecordPresentation(active); return (
{t("recordSharing.banner.viewing", { name })} {" "} - {/* Resolved server-side, and it says something at BOTH levels now. - v1.36.1 is the release that made `canWrite` true, and until this - line changed the write case rendered nothing at all: a delegate who - could add to somebody's health record was told only whose record it - was. That matters more than the read case, not less, and the rest of - the product leans on it — a control the record refuses is absent - rather than disabled, on the stated grounds that the banner names - the mode globally. It has to actually do that. */} - {active.canWrite - ? t("recordSharing.banner.canAdd") - : t("recordSharing.banner.readOnly")} + {recordKindLabel(presentation.recordKind, t)}.{" "} + {accessLabel(presentation.access, t)}