diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx new file mode 100644 index 000000000..e132d385c --- /dev/null +++ b/src/components/layout/account-switcher-menu.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Check, Loader2, Users } from "lucide-react"; + +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; + +/** + * v1.36.0 — the switcher, as a slice of the user menu. + * + * One component mounted in both user menus (the desktop sidebar's avatar + * dropdown and the mobile top bar's) rather than the same block written twice. + * The two menus have drifted before — that is the whole argument behind + * `nav-model.ts` — and a switcher that exists on one surface and not the other + * is a person who can enter a record on their laptop and cannot leave it on + * their phone. + * + * Renders NOTHING unless the server says there is somewhere to go: `canSwitch` + * arrives resolved on `accountAccess` and is bound directly. The list is + * `accounts`, also resolved — the client never looks at grant rows and has none + * to look at. An account that has never been shared with sees a user menu + * identical to the one it saw before this feature existed. + * + * "Your own record" is a first-class entry rather than only a banner action: + * the way out has to be reachable from the same menu as the way in, because + * that is where somebody will look for it. + */ +export function AccountSwitcherMenuItems() { + const { user } = useAuth(); + const { t } = useTranslations(); + const switchAccount = useAccountSwitch(); + + const access = user?.accountAccess; + if (!access?.canSwitch) return null; + + const activeId = access.active?.accountId ?? null; + + return ( + <> + + + + {switchAccount.isPending ? ( + + + switchAccount.mutate(null)} + > + + {t("recordSharing.switcher.ownRecord")} + + {activeId === null && ( + ✓ + )} + + {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 && ( + + )} +
+ ))} +
+
+ + ); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 3fa3a14d5..158eb17e1 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -12,9 +12,12 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; +import { isDestinationInSharedRecord } from "./nav-model"; import { BottomNav } from "./bottom-nav"; import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; +import { SharedRecordBanner } from "./shared-record-banner"; +import { SharedRecordUnavailable } from "./shared-record-unavailable"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -69,6 +72,14 @@ export function AuthShell({ pathname.startsWith("/about") || pathname.startsWith("/c/"); const isAdminPage = pathname.startsWith("/admin"); + // 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 + // it instead of letting the page paint a row of 403 error cards. Paint only + // — every route behind these pages refuses on its own. + const outsideSharedRecord = + user?.accountAccess?.active != null && + !isDestinationInSharedRecord(pathname); const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; @@ -301,6 +312,13 @@ export function AuthShell({ the first chrome line a user sees in the offline branch. */} + {/* v1.36.0 — whose record is open. Sits directly under the + connection strip and above everything else: a person who has + forgotten they are switched is about to write a reading into + somebody else's record, and no other line of chrome is worth + seeing first. Renders nothing when the session is in its own + account, which is every session that has never used sharing. */} + {/* Demo instances surface a persistent "changes are not saved" strip — the proxy blocks every mutation, and without the banner that block only surfaced as a raw API @@ -357,7 +375,7 @@ export function AuthShell({ data-slot="main-content-wrapper" className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6" > - {children} + {outsideSharedRecord ? : children} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index e607fad92..3b67fa163 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -14,6 +14,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, + isDestinationInSharedRecord, mobileMoreHubDestinations, } from "@/components/layout/nav-model"; import type { ModuleKey } from "@/lib/modules/registry"; @@ -92,6 +93,10 @@ export function BottomNav() { // module from flickering into the hub / primary slot before the query // resolves (the #418-class divergence); once mounted the real map applies. const mounted = useMounted(); + // 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; // v1.17.1 (F-1) — the More hub is the model-computed hub: every visible // feature destination that isn't a primary slot, plus the shared utility @@ -102,8 +107,9 @@ export function BottomNav() { mobileMoreHubDestinations({ modules: user?.modules, mounted, + sharedRecord, }), - [user?.modules, mounted], + [user?.modules, mounted, sharedRecord], ); // v1.18.0 — drop a module-gated primary slot (Insights) when the account @@ -115,10 +121,14 @@ export function BottomNav() { () => PRIMARY_RIGHT.filter( (item) => - !item.requiresModule || - (mounted && user?.modules?.[item.requiresModule] !== false), + // 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)) && + (!item.requiresModule || + (mounted && user?.modules?.[item.requiresModule] !== false)), ), - [user?.modules, mounted], + [user?.modules, mounted, sharedRecord], ); function isActiveLink(href: string) { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 7dacefed9..f8e8ab0b7 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -57,6 +57,24 @@ export interface NavDestination { * not a re-derivation. */ requiresModule?: ModuleKey; + /** + * v1.36.0 — is this destination part of what account sharing covers. + * + * `false` (the default, so the omission is the safe answer) means the entry + * disappears while the browser is acting on somebody else's record. It is + * PAINT and nothing else: the server refuses every non-delegable route under + * a switch on its own, from a frozen allowlist that this file cannot reach + * and does not mirror. Hiding the entry only spares a delegate a click that + * ends in a refusal — both ends, always, and this is the cosmetic end. + * + * The line the flag draws is the design's (§4, §6): a delegate reads the + * health RECORD, and never the account around it. So the tracking surfaces + * are marked, and everything that configures, connects, exports, or asks an + * AI about the account is not. AI is the one that looks arbitrary and is + * not: server-managed LLM egress of the owner's data rides a consent the + * owner gave for their own use, so those surfaces stay owner-only in v1. + */ + sharedRecord?: boolean; } /** @@ -69,15 +87,23 @@ export interface NavDestination { // previous: Medications → Vorsorge → Labs → Illness → Insights → Coach → // Achievements. A disabled module simply drops out; the rest keep this order. export const NAV_DESTINATIONS: ReadonlyArray = [ - { href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" }, + { + href: "/", + sharedRecord: true, + tKey: "nav.dashboard", + icon: Home, + tourId: "nav-dashboard", + }, { href: "/measurements", + sharedRecord: true, tKey: "nav.measurements", icon: Activity, tourId: "nav-measurements", }, { href: "/mood", + sharedRecord: true, tKey: "nav.mood", icon: Waves, tourId: "nav-mood", @@ -91,6 +117,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // no longer borrows the Insights tab strip / layout shell). { href: "/mental-wellbeing", + sharedRecord: true, tKey: "nav.mentalWellbeing", icon: Brain, tourId: "nav-mental-wellbeing", @@ -98,6 +125,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/cycle", + sharedRecord: true, tKey: "nav.cycle", icon: Droplets, tourId: "nav-cycle", @@ -105,6 +133,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/medications", + sharedRecord: true, tKey: "nav.medications", icon: Pill, tourId: "nav-medications", @@ -119,6 +148,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // Reminders hub still links to it; this is the direct front door. { href: "/checkups", + sharedRecord: true, tKey: "nav.vorsorge", icon: Stethoscope, tourId: "nav-vorsorge", @@ -132,6 +162,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/labs", + sharedRecord: true, tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs", @@ -143,6 +174,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // absent until the account turns the module on from the Modules hub. { href: "/illness", + sharedRecord: true, tKey: "nav.illness", icon: Thermometer, tourId: "nav-illness", @@ -158,11 +190,17 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // until the account turns the module on from the Modules hub. { href: "/documents", + sharedRecord: true, tKey: "nav.documents", icon: FileScan, tourId: "nav-documents", requiresModule: "inboundDocuments", }, + // v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they + // drop out under a switch. Both are AI surfaces, and AI egress of a + // person's health data rides the consent THAT person gave for their own + // use; a delegate triggering it would create a consent-shaped act the owner + // never made. Non-delegable in v1, server-side and here (design §4). { href: "/insights", tKey: "nav.insights", @@ -183,6 +221,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, { href: "/achievements", + sharedRecord: true, tKey: "nav.achievements", icon: Trophy, tourId: "nav-achievements", @@ -224,7 +263,15 @@ export const NAV_UTILITY_DESTINATIONS: ReadonlyArray = [ * tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two * surfaces share one definition of the utility links. */ -export function visibleUtilityDestinations(): NavUtilityDestination[] { +export function visibleUtilityDestinations( + opts: { sharedRecord?: boolean } = {}, +): NavUtilityDestination[] { + // v1.36.0 — the utility tail is account configuration by definition: + // Settings holds credentials, integrations, notification channels and grant + // management, and Notifications is the delegate's own device business. Every + // route behind both refuses under a switch; the tail simply stops being + // offered so nobody walks into the refusal. + if (opts.sharedRecord) return []; return [...NAV_UTILITY_DESTINATIONS]; } @@ -257,25 +304,53 @@ function isNavDestinationVisible( d: NavDestination, modules: ModuleVisibilityMap | undefined, mounted = true, + sharedRecord = false, ): boolean { + if (sharedRecord && d.sharedRecord !== true) return false; if (!d.requiresModule) return true; if (!mounted) return false; return modules?.[d.requiresModule] !== false; } +/** + * v1.36.0 — is this path part of what account sharing covers? + * + * Answers from the one destination list rather than from a second literal, so + * the mobile bar's fixed slots, the sidebar and the shell's deep-link guard + * cannot end up disagreeing about which surfaces a delegate is offered. + * + * Matches a destination and everything beneath it (`/measurements/123` rides + * `/measurements`), with the dashboard matching exactly — otherwise `"/"` + * would swallow every path in the app. A path no destination claims answers + * `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}/`); + }); +} + /** * The ordered destinations visible to this account — drops a module-gated * entry (mood, cycle, labs, coach, achievements …) when its module is * disabled in the account's resolved module map. Both bars start from this. * v1.18.0 — cycle is no longer a bespoke boolean: it is `requiresModule: * "cycle"` and reads the delegated `cycle` key from the same map. + * + * v1.36.0 — `sharedRecord` drops every entry that is not part of what sharing + * covers, so a delegate is not offered a door the server will shut. Paint + * only; see the `sharedRecord` field's docblock. */ export function visibleNavDestinations( modules: ModuleVisibilityMap | undefined, mounted = true, + sharedRecord = false, ): NavDestination[] { return NAV_DESTINATIONS.filter((d) => - isNavDestinationVisible(d, modules, mounted), + isNavDestinationVisible(d, modules, mounted, sharedRecord), ); } @@ -312,8 +387,14 @@ export function mobileMoreHubDestinations(opts: { modules: ModuleVisibilityMap | undefined; /** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */ mounted?: boolean; + /** v1.36.0 — acting on somebody else's record. Defaults to own. */ + sharedRecord?: boolean; }): MobileMoreHubEntry[] { - return visibleNavDestinations(opts.modules, opts.mounted ?? true) + return visibleNavDestinations( + opts.modules, + opts.mounted ?? true, + opts.sharedRecord ?? false, + ) .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/shared-record-banner.tsx b/src/components/layout/shared-record-banner.tsx new file mode 100644 index 000000000..ca31f12d1 --- /dev/null +++ b/src/components/layout/shared-record-banner.tsx @@ -0,0 +1,89 @@ +"use client"; + +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 { accountLabel } from "@/lib/sharing/account-access-view"; + +/** + * v1.36.0 — the strip that says whose record is open. + * + * This is the feature, not decoration around it. A caregiver who forgets they + * are switched will log their own blood pressure into somebody else's record, + * and there is no undo for a reading that was never theirs. So the banner + * states three things in one line — that this is not your record, whose it is, + * and that you can only read it — and carries the way out beside them. + * + * ## Why it looks like this + * + * Loud on purpose, within the token system. `bg-warning/15` + `border-warning/40` + * puts it in the one visual register the app reserves for "something about this + * screen is not normal", above the offline strip and the demo strip in the + * shell so it is the first line of chrome under any circumstance. Not + * `destructive` — nothing is broken and nothing is at risk of being lost; + * warning is the honest register for a context the user has to hold in mind. + * Not a tint on the page background either: a subtle wash is exactly what + * somebody stops seeing after the third switch, and the failure mode here is + * a person who has stopped noticing. + * + * The name is `text-foreground` and the qualifier `text-muted-foreground`, per + * the two-tier text rule — the person's name is content, the read-only note is + * meta. Mounted for every authenticated route inside `` (the + * `` pattern) so no page can forget it, and rendered from + * `accountAccess.active`, which the server resolves: the banner appears exactly + * when the server says the session is inside a record, never on a client guess. + * + * `role="status"` rather than `alert`: assistive tech announces the context + * once when it appears, without the interrupt an alert implies. `aria-live` is + * left to the role's implicit `polite`. + */ +export function SharedRecordBanner() { + const { user } = useAuth(); + const { t } = useTranslations(); + const switchAccount = useAccountSwitch(); + + const active = user?.accountAccess?.active ?? null; + if (!active) return null; + + const name = accountLabel(active); + + return ( +
+
+ ); +} diff --git a/src/components/layout/shared-record-grant-loss-bridge.tsx b/src/components/layout/shared-record-grant-loss-bridge.tsx new file mode 100644 index 000000000..ce21ad625 --- /dev/null +++ b/src/components/layout/shared-record-grant-loss-bridge.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +import { + isSharingAccessDenied, + useLeaveSharedRecordOnGrantLoss, +} from "@/hooks/use-account-switch"; + +/** + * v1.36.0 — leave a record the server has stopped honouring. + * + * The switcher reset the contract asks for. A grant can end at any moment: the + * owner presses revoke, or an expiry passes. Revocation clears the delegate's + * acting sessions in the same transaction, so that path lands them back in + * their own account by itself. **Expiry does not** — nothing sweeps, the grant + * simply stops being live on the next request, and the session keeps its stamp. + * A browser in that state gets a 403 on every read while the banner still says + * it is inside somebody's record. Correct refusals under a screen that lies. + * + * So one subscriber watches the query cache for the stable + * `sharing.access.denied` code and, on the first one, clears the switch and + * reloads. Watching the cache rather than asking ~200 call sites to handle a + * code is the point: a per-call-site handler is a rule the next feature + * forgets, and forgetting it here means somebody is stuck. + * + * Fires once. `landOnRecord` never resolves (the document is being replaced), + * so a second trigger would queue a second reload behind a navigation already + * in flight; the ref makes the first one the only one. + */ +export function SharedRecordGrantLossBridge() { + const queryClient = useQueryClient(); + const leave = useLeaveSharedRecordOnGrantLoss(); + const leaving = useRef(false); + + useEffect(() => { + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if (leaving.current) return; + if (event.type !== "updated") return; + if (event.query.state.status !== "error") return; + if (!isSharingAccessDenied(event.query.state.error)) return; + leaving.current = true; + void leave(); + }); + return unsubscribe; + }, [queryClient, leave]); + + return null; +} diff --git a/src/components/layout/shared-record-unavailable.tsx b/src/components/layout/shared-record-unavailable.tsx new file mode 100644 index 000000000..aa9775faf --- /dev/null +++ b/src/components/layout/shared-record-unavailable.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Lock } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { useAccountSwitch } from "@/hooks/use-account-switch"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.36.0 — the page a delegate reached that sharing does not cover. + * + * The nav drops these destinations while a switch is on, so the way here is a + * bookmark, a browser back button, or a link somebody kept open. Without this + * panel the page would mount, fire its reads, collect a row of 403s, and paint + * a screen full of error cards that each say something went wrong — when + * nothing did. + * + * It says the honest thing instead: this surface is not part of what sharing + * covers, and here is the way back to your own record. Settings, credentials, + * integrations, notification channels and the AI surfaces are the account + * around a health record rather than the record itself; a delegate reads the + * record. + * + * Paint, not enforcement. Every route behind these pages refuses on its own, + * from a frozen allowlist this component cannot reach — if this file were + * deleted the delegate would see 403s, not data. + */ +export function SharedRecordUnavailable() { + const { t } = useTranslations(); + const switchAccount = useAccountSwitch(); + + return ( +
+ } + title={t("recordSharing.unavailable.title")} + description={t("recordSharing.unavailable.description")} + ctaSize="lg" + action={ + + } + /> +
+ ); +} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 0e6aa9cb9..030f8438b 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -16,6 +16,7 @@ import { import Link from "next/link"; import { usePathname } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; +import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, @@ -146,6 +147,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { + {/* v1.36.0 — the records this account may open. Renders nothing when + nobody has shared one, so the menu is unchanged for every account + that does not use sharing. */} + logout.mutate()} @@ -250,9 +255,13 @@ export function SidebarNav() { // only) and stops a disabled module's entry from flickering in for one // frame before the query resolves; once mounted the real map applies. const mounted = useMounted(); + // 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 visibleNavItems = useMemo( - () => visibleNavDestinations(user?.modules, mounted), - [user?.modules, mounted], + () => visibleNavDestinations(user?.modules, mounted, sharedRecord), + [user?.modules, mounted, sharedRecord], ); // Collapsed = the user's stored choice, or — with no stored choice — a // viewport default: tablet widths (md–lg, e.g. an iPad held upright) @@ -295,8 +304,10 @@ export function SidebarNav() { // role-gated, sidebar-only surface and is inserted separately below. const footerUtilityItems = useMemo( () => - visibleUtilityDestinations().filter((d) => d.href !== "/notifications"), - [], + visibleUtilityDestinations({ sharedRecord }).filter( + (d) => d.href !== "/notifications", + ), + [sharedRecord], ); function isUtilityActive(href: string) { diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index d03b572c9..c70730d26 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -11,6 +11,7 @@ import { Shield, Sun, } from "lucide-react"; +import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu"; import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; @@ -177,6 +178,11 @@ export function TopBar() { + {/* v1.36.0 — the same switcher slice the desktop sidebar menu + mounts. One component in both menus: a switcher a person can + reach on their laptop and not on their phone is a record they + can enter and cannot leave. */} + logout.mutate()} diff --git a/src/components/providers.tsx b/src/components/providers.tsx index e137a2c9e..37dfa184d 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -21,6 +21,7 @@ import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; import { VersionPoller } from "@/components/version-poller"; import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; +import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications"; @@ -281,6 +282,11 @@ export function Providers({ + {/* v1.36.0 — a grant that ends while a browser is inside the record + leaves the session stamped and every read refused. One cache + subscriber notices the stable errorCode and puts the browser back + in its own account, rather than asking every call site to. */} + {children} diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts new file mode 100644 index 000000000..547dcbfe0 --- /dev/null +++ b/src/hooks/use-account-switch.ts @@ -0,0 +1,166 @@ +"use client"; + +/** + * v1.36.0 — entering and leaving somebody else's record, from the browser. + * + * The switch is three acts in a fixed order, and the order is the whole + * safety argument: + * + * 1. **Ask the server.** `POST /api/account/switch` validates the grant and + * stamps the session. Nothing local changes until it answers, so a switch + * the server refuses leaves the browser exactly where it was. + * 2. **Empty every cache that survives a reload.** The IndexedDB query + * snapshot and the service worker's `healthlog-data-*` cache both answer + * reads from disk. Left in place, the first paint on the other side can + * be the PREVIOUS record's numbers under a banner naming the new one — + * and nothing in the page's own code would be wrong, because the answer + * would not have come from the page. Awaited, not fired: a wipe racing + * the navigation is a wipe that sometimes does not happen. + * 3. **Reload the document.** Not `router.refresh()`, not a query + * invalidation. A hard navigation is what discards the in-memory cache, + * every React state tree holding a rendered value, and every in-flight + * request that was issued against the old record. The reload IS the cache + * flush; the two lines above it are the parts a reload cannot reach. + * + * The hook returns from step 1 and never from step 3 — the page is gone. + * Callers therefore do not need a success state, only a pending one. + */ + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { toast } from "sonner"; + +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister"; +import { queryKeys } from "@/lib/query-keys"; +import { setRecordScope } from "@/lib/query-keys/record-scope"; + +/** + * The two stable sharing error codes, as the envelope publishes them + * (`meta.errorCode`). Named here rather than typed inline so the reset + * below and the "not available while sharing" state cannot drift apart from + * what the server sends. + */ +export const SHARING_ACCESS_DENIED = "sharing.access.denied"; +export const SHARING_NOT_PERMITTED = "sharing.not_permitted"; + +/** The `meta.errorCode` on a failed request, when it carried one. */ +export function errorCodeOf(error: unknown): string | null { + if (!(error instanceof ApiError)) return null; + const code = error.meta?.errorCode; + return typeof code === "string" ? code : null; +} + +/** + * True when a request failed because the caller may no longer act as the + * account they are inside — the grant was revoked, lapsed, or never applied to + * this record. + * + * The correct response is not a retry and not an error card: it is to leave the + * record. A browser that stays inside it shows a banner claiming access it does + * not have, over a page that will 403 every read. + */ +export function isSharingAccessDenied(error: unknown): boolean { + return errorCodeOf(error) === SHARING_ACCESS_DENIED; +} + +/** + * True when the surface itself is not available while acting on another + * account — grant management, credentials, integrations, notification + * channels, anything that would let a delegate extend their own reach. + * + * A different fact from the one above and worth a different message: there is + * nothing wrong with the caller's access, the page simply is not part of what + * sharing covers. + */ +export function isSharingNotPermitted(error: unknown): boolean { + return errorCodeOf(error) === SHARING_NOT_PERMITTED; +} + +interface SwitchResponse { + actingAs: { + accountId: string; + username: string; + displayName: string | null; + access: "READ" | "WRITE"; + } | null; +} + +async function postSwitch(accountId: string | null): Promise { + return apiPost("/api/account/switch", { accountId }); +} + +/** + * Complete the switch: stamp the cache scope, empty the disk layers, reload. + * + * Extracted from the mutation so the grant-loss reset can reuse the exact same + * sequence. Two copies of "wipe, then reload" is one copy too many for a step + * whose order is the safety property. + */ +async function landOnRecord(accountId: string | null): Promise { + setRecordScope(accountId); + await clearOfflineCachesForRecordSwitch(); + window.location.assign("/"); + // The document is being replaced. Nothing after this runs, and the promise + // deliberately never settles so no caller paints a "done" state over a page + // that is on its way out. + return new Promise(() => {}); +} + +/** + * Switch into a record, or (with `null`) back into the caller's own. + * + * Errors surface as a toast and leave the browser in place — including the + * `sharing.access.denied` case, which means the grant ended between the + * switcher being painted and the click. The list the switcher renders comes + * from `/api/auth/me`, so the next boot will not offer it again. + */ +export function useAccountSwitch() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: queryKeys.accountSwitchMutation(), + mutationFn: async (accountId: string | null) => { + await postSwitch(accountId); + // The in-memory cache holds the record we are leaving. Clearing it here + // as well as reloading costs nothing and closes the window in which a + // failed navigation would leave the old entries reachable. + queryClient.clear(); + return landOnRecord(accountId); + }, + onError: (error: unknown) => { + toast.error( + isSharingAccessDenied(error) + ? t("recordSharing.switch.noLongerAvailable") + : t("recordSharing.switch.failed"), + ); + }, + }); +} + +/** + * Leave a record the server has stopped honouring. + * + * The switcher-reset the contract asks for: on `sharing.access.denied` the + * session is still stamped with an account it may no longer act on, and every + * delegable read from here on is a 403. Clearing the stamp puts the browser + * back in its own account rather than leaving somebody staring at a wall of + * refusals under a banner that says they are somewhere they are not. + * + * `POST /api/account/switch` with `null` is an actor surface and always + * succeeds — it is the way out, by design. If even that fails (offline, server + * down) the reload still happens: the stamp survives, but so does the honest + * refusal, and the alternative is a page that neither recovers nor says why. + */ +export function useLeaveSharedRecordOnGrantLoss() { + return useCallback(async () => { + try { + await postSwitch(null); + } catch { + /* the reload below is the part that matters */ + } + await landOnRecord(null); + }, []); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index f0da3add3..b698f8033 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -22,6 +22,19 @@ import { storeTimezone } from "@/lib/timezone-mirror"; import type { ModuleKey } from "@/lib/modules/registry"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { setRecordScope } from "@/lib/query-keys/record-scope"; +import type { AccountAccess } from "@/lib/sharing/account-access-view"; + +/** + * v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back + * to when the payload carries no block, so every consumer reads a real object + * and none of them has to spell out what a missing block would mean. + */ +export const NO_ACCOUNT_ACCESS: AccountAccess = { + accounts: [], + active: null, + canSwitch: false, +}; /** * v1.18.6 — resume point for the module tour as the client sees it on @@ -162,6 +175,21 @@ export interface AuthUser { * payload so a missing map reads as all-available. */ moduleAvailability?: Partial>; + /** + * v1.36.0 — account sharing, resolved server-side. `accounts` is the + * switcher's menu, `active` is the record this browser is inside (null when + * it is in its own), `canSwitch` and per-entry `canWrite` are booleans to + * bind, never to compute. + * + * Nothing in the UI may derive a permission from this block: the server + * publishes what the caller may do and the client renders it, because two + * programs deciding one person's access is how they end up disagreeing. + * + * Optional so existing `AuthUser` test fixtures stay valid; `fetchMe` + * always populates it, coercing an absent block (an older server image) to + * the empty one — no accounts, no active record, no switcher. + */ + accountAccess?: AccountAccess; } /** @@ -264,6 +292,13 @@ async function fetchMe(): Promise { storeTimezone(typeof data.timezone === "string" ? data.timezone : ""); // A confirmed session: refresh the non-sensitive offline-relaunch marker. storeWasAuthenticated(); + // v1.36.0 — the record this browser is reading, straight from the server's + // resolved answer. This is the correcting write for the cache-scope mirror: + // whatever the switch flow stamped before its reload, the block is what the + // resolver will actually honour on the next request, so the mirror tracks it + // rather than the client's memory of what it asked for. + const accountAccess = coerceAccountAccess(data.accountAccess); + setRecordScope(accountAccess.active?.accountId ?? null); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, @@ -290,6 +325,28 @@ async function fetchMe(): Promise { data.moduleAvailability && typeof data.moduleAvailability === "object" ? data.moduleAvailability : {}, + accountAccess, + }; +} + +/** + * Read the sharing block off a payload that may not carry one. + * + * Fails CLOSED, unlike the module maps above: an older server image (or a + * malformed block) resolves to no accounts, no active record, and no switcher. + * The module gates fail open because a missing map must never blank the nav; + * this one fails closed because the affordance it drives asks the server to + * open somebody else's health record, and an affordance offered on a server + * that cannot honour it is worse than one that is missing. + */ +function coerceAccountAccess(value: unknown): AccountAccess { + if (value === null || typeof value !== "object") return NO_ACCOUNT_ACCESS; + const block = value as Partial; + if (!Array.isArray(block.accounts)) return NO_ACCOUNT_ACCESS; + return { + accounts: block.accounts, + active: block.active ?? null, + canSwitch: block.canSwitch === true, }; } @@ -358,6 +415,12 @@ export function useAuth() { */ export function clearCachesForSessionEnd(queryClient: QueryClient): void { queryClient.clear(); + // v1.36.0 — the cache-scope mirror is session state too. Left behind, the + // next account on this browser would boot with the previous session's record + // scope, which partitions its cache under a stranger's id until the first + // `/api/auth/me` corrects it. Harmless for correctness (the store it points + // at is wiped below) and free to get right. + setRecordScope(null); // The offline-relaunch marker is session state too: after a real session // end the next cold offline launch must land on the login gate, not the // cached shell.