feat: scope shared record navigation

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 04:13:59 +02:00
1 parent cb15100341
commit afae57207c
5 files changed
+138 -18

No files matched your search

+2 -2
View File
@@ -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;
+10 -5
View File
@@ -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) {
+20 -8
View File
@@ -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 }));
+5 -3
View File
@@ -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)
+101
View File
@@ -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<ShareDomain, readonly `/${string}`[]>;
/**
* 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);
}