From dd3d5646de97331a1bc5793bca951aff763f7bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 14:26:26 +0200 Subject: [PATCH] feat(sharing): the switch, the banner, and the nav inside another record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching is three acts in a fixed order, and the order is the safety argument. Ask the server, so a refused switch leaves the browser where it was. Empty the caches a reload cannot reach, awaited rather than fired, because a wipe racing the navigation is a wipe that sometimes does not happen. Then reload the document, which is what discards the in-memory cache, the rendered React trees, and every request already in flight against the old record. The banner is the feature, not decoration. Someone who forgets they are switched will log their own blood pressure into another person's record, and there is no undo for a reading that was never theirs. So it states whose record is open, that it is read-only, and carries the way out — in the one visual register reserved for "something about this screen is not normal", at the top of the shell above every other strip. A subtle tint is what people stop seeing on the third switch. The switcher lives in one component mounted in both user menus. A way in on the laptop and no way out on the phone is how the two menus have drifted before. Nav entries that sharing does not cover drop out, and a page reached by bookmark says so instead of painting a row of refusals. Both are paint: the server refuses those routes on its own, from a frozen list this code cannot reach. Settings, connected services, notifications and the AI surfaces are the account around a record rather than the record itself. A grant can end while a browser is inside the record. Revocation clears the delegate's sessions in the same transaction; expiry does not, and leaves a session stamped with an account it may no longer act on — correct refusals under a screen that lies. One cache subscriber notices the stable errorCode and leaves, rather than asking two hundred call sites to remember. --- .../layout/account-switcher-menu.tsx | 110 ++++++++++++ src/components/layout/auth-shell.tsx | 20 ++- src/components/layout/bottom-nav.tsx | 18 +- src/components/layout/nav-model.ts | 89 +++++++++- .../layout/shared-record-banner.tsx | 89 ++++++++++ .../shared-record-grant-loss-bridge.tsx | 50 ++++++ .../layout/shared-record-unavailable.tsx | 52 ++++++ src/components/layout/sidebar-nav.tsx | 19 +- src/components/layout/top-bar.tsx | 6 + src/components/providers.tsx | 6 + src/hooks/use-account-switch.ts | 166 ++++++++++++++++++ src/hooks/use-auth.ts | 63 +++++++ 12 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 src/components/layout/account-switcher-menu.tsx create mode 100644 src/components/layout/shared-record-banner.tsx create mode 100644 src/components/layout/shared-record-grant-loss-bridge.tsx create mode 100644 src/components/layout/shared-record-unavailable.tsx create mode 100644 src/hooks/use-account-switch.ts 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.