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); +}