mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat: scope shared record navigation
This commit is contained in:
1 parent
cb15100341
commit
afae57207c
5 files changed
+138
-18
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user