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 ? (
+
+ ) : (
+
+ )}
+ {t("recordSharing.switcher.label")}
+
+
+ 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 (
+
+
+
+ {t("recordSharing.banner.viewing", { name })}
+
+ {/* Resolved server-side. `canWrite` is false for every grant in v1, and
+ the day it is not, this line stops claiming otherwise on its own. */}
+ {!active.canWrite && (
+
+ {t("recordSharing.banner.readOnly")}
+
+ )}
+
+
+ );
+}
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 (
+
+ );
+}
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.