"use client"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier"; import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; 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"; // v1.4.27 MB6 — `/about` joins the public-path list so the GeoLite2 // CC BY-SA 4.0 attribution stays reachable for unauthenticated // visitors. The `/about` route is already registered in `proxy.ts` // PUBLIC_PATHS; this constant gates the client-side auth shell, and // both lists must agree or the route round-trips through the // sign-in redirect. const PUBLIC_PATHS = [ "/auth/login", "/auth/register", "/privacy", "/about", // v1.11.0 — the public clinician view renders its own standalone chrome // (no nav / coach / auth gate). Listed here so the shell hands it through // bare; the route is also in `proxy.ts` PUBLIC_PATHS so it never round-trips // the sign-in redirect. "/c/", ]; export function AuthShell({ children, demoMode = false, }: { children: React.ReactNode; /** * True when the instance runs with `DEMO_MODE=true` (resolved by the * server-side root layout). Renders the persistent demo banner above * the app chrome so a visitor knows mutations won't persist. */ demoMode?: boolean; }) { const { user, isAuthenticated, isAuthUnknown, isLoading } = useAuth(); const { t } = useTranslations(); const pathname = usePathname(); const router = useRouter(); const queryClient = useQueryClient(); const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); // v1.4.26 — `/privacy` is a long-form legal page that brings its own // header + footer. Centering it inside the public-page wrapper would // squash a 3000-word policy into a "login card" layout, so we let it // render edge-to-edge while still resolving as a public page for the // auth gate above. // v1.4.27 MB6 — `/about` follows the same shape (own header, own // footer, full-width body), so it joins the standalone list. // v1.11.0 — the clinician view (`/c/*`) renders edge-to-edge with its own // standalone chrome and no app shell, like the legal pages. const isStandalonePublicPage = pathname.startsWith("/privacy") || pathname.startsWith("/about") || pathname.startsWith("/c/"); const isAdminPage = pathname.startsWith("/admin"); // v1.36.0 — is this browser inside somebody else's record. Read from the // capability hook rather than off `accountAccess.active` directly: the shell // decides what to mount and the surfaces beneath it decide what to offer, // and the two disagreeing is the whole class of defect this hook exists to // close. const { inSharedRecord, recordKind } = useRecordCapabilities(); // A managed profile has a deliberately small Settings surface of its own. // Let that route family reach its record-aware gate; adult shared records // stay in the generic refusal branch even at MANAGE. const isManagedRecordSettingsPath = recordKind === "managed" && pathname.startsWith("/settings"); // 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 = inSharedRecord && !isDestinationInSharedRecord(pathname) && !isManagedRecordSettingsPath; const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; // v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved // only for the body-scrolled shells (login / register / onboarding / // standalone legal pages). The authenticated branch is height-locked and // scrolls inside its own `
`, which reserves its own gutter; applying // the document gutter there too produced a second, never-painted gutter on // classic-scrollbar platforms. Flag the body-scrolled branches on `` // so the CSS rule scopes to them. const isBodyScrolled = isPublicPage || isOnboardingPage; useEffect(() => { const root = document.documentElement; if (isBodyScrolled) { root.setAttribute("data-scroll", "body"); } else { root.removeAttribute("data-scroll"); } return () => { root.removeAttribute("data-scroll"); }; }, [isBodyScrolled]); // v1.25.1 — the authenticated shell keeps `
` // mounted across every route change, so its scroll offset survives // navigation: scroll down in one section (e.g. Settings → Sources // Priority), hop to another, and the new view opens already scrolled. // Reset the scroll container to the top on every forward navigation. // A back/forward navigation skips the reset so the browser's own scroll // restoration stands: `popstate` arms a one-shot skip for the single // pathname change it triggers, and a macrotask clears the flag so it can // never get stuck on a later forward navigation. const skipNextScrollReset = useRef(false); useEffect(() => { const armSkip = () => { skipNextScrollReset.current = true; setTimeout(() => { skipNextScrollReset.current = false; }, 0); }; window.addEventListener("popstate", armSkip); return () => window.removeEventListener("popstate", armSkip); }, []); useEffect(() => { if (skipNextScrollReset.current) { skipNextScrollReset.current = false; return; } const main = document.getElementById("main-content"); if (main) { main.scrollTop = 0; } // Fallback for the body-scrolled shells (login / onboarding / standalone // legal pages) where the document, not `
`, owns the scroll. window.scrollTo(0, 0); }, [pathname]); useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { // Session END (expired / invalidated cookie → `/api/auth/me` 401/403). // Wipe the in-memory query cache (the cross-user guard — the // QueryClient outlives the navigation on this SPA and the health-data // families are not user-scoped) plus the persisted IndexedDB + SW // caches, so nothing leaks to the next account on a shared browser. // Logout does the same from `useLogout`; this covers the expiry path // logout never runs through. Best-effort; the redirect proceeds // regardless. // // `isAuthUnknown` = the auth probe failed WITHOUT a session verdict // (offline relaunch, network blip, 5xx) and no prior-session marker // exists. Nothing to protect and nothing to render — send the visitor // to the login gate, but never run the wipe off a non-verdict: the // wipe on a network failure is exactly what used to destroy the // offline caches. (With the marker present, `isAuthenticated` holds // the last-known state and this branch never runs.) if (!isAuthUnknown) { clearCachesForSessionEnd(queryClient); } router.replace("/auth/login"); } }, [ isLoading, isAuthenticated, isAuthUnknown, isPublicPage, router, queryClient, ]); // Redirect non-admins away from /admin useEffect(() => { if ( !isLoading && isAuthenticated && isAdminPage && user?.role !== "ADMIN" ) { router.replace("/"); } }, [isLoading, isAuthenticated, isAdminPage, user?.role, router]); // v1.4.22 C4 — onboarding redirect moved to proxy.ts so it lands on // the first server response instead of post-hydration. The proxy // reads the `hl_onboarding` cookie (mirrored from the DB by the auth // routes) and 307s to /onboarding before this shell ever paints. The // previous `useEffect`-based redirect caused a brief dashboard flash // for users with `onboardingCompletedAt === null`. // Public pages (login, register) — render without any nav. Resolved // BEFORE the auth-loading gate: a public page never needs the // `/api/auth/me` round-trip to paint, so blocking it on `isLoading` // only delayed the login form behind a spinner. if (isPublicPage) { // Long-form legal pages render edge-to-edge with their own chrome. if (isStandalonePublicPage) { return <>{children}; } return (
{demoMode ? : null}
{children}
); } // Admin pages stay behind the auth gate while `/api/auth/me` is in // flight: the role is unknown until the payload lands, and mounting // `/admin/*` children early would fire admin queries that 403 for a // non-admin before the redirect effect can move them away. if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) { return (
{t("nav.loadingScreen")}
); } // Auth RESOLVED as unauthenticated — hold a spinner while the // redirect effect above replaces the route with /auth/login. if (!isLoading && !isAuthenticated) { return (
{t("nav.loadingScreen")}
); } // While `/api/auth/me` is still in flight the shell renders the full // app chrome + children immediately (the chrome components own their // null-user skeletons, pages own their data skeletons). This takes the // auth round-trip off the first-paint critical path: page-level // queries fire in parallel with `/api/auth/me` instead of behind it. // `src/proxy.ts` has already refused cookie-less requests to // protected routes, so the unauthenticated-flash window is limited to // expired/invalid sessions — those resolve into the redirect branch // above as soon as the 401 lands. // Onboarding page — minimal shell, no sidebar/nav if (isOnboardingPage) { return ( <> {showUnlockNotifier && user?.id ? ( ) : null}
{children}
); } // Authenticated — full app shell. // // v1.4.34 IW-B — the Coach launch provider lives here (not inside // `app/insights/layout.tsx` anymore) so every authenticated route can // call `askCoach()` from the same context. Pre-hoist the drawer was // only reachable from `/insights/**`; the dashboard hero CTA now opens // it without a route hop. `` consumes the same // context to render the drawer once at the shell level. // // v1.16.8 — `` joins the shell too: the floating // Coach launcher renders once here for every authenticated route // (it hides itself on `/coach` and carries the unread-nudge // dot). The routed insights layout no longer mounts it. return ( {showUnlockNotifier && user?.id ? ( ) : null} {/* Keyboard-only skip link. The non-focused state has both `pointer-events-none` (so a mouse click in the top-left corner passes through to the logo behind it) AND `-translate-y-full` (so even an accidental tap with stylus / touch can't activate it). Focus restores both — the element jumps back into the viewport and becomes clickable. */} {/* The banner stack is PAGE-level, above the sidebar/content row, and that placement is the whole point rather than a detail of the markup. A banner is a strip about the whole window — the connection is down, this is somebody else's record, this instance saves nothing — and none of those statements is about the content column. Inside the column, which is where they lived until v1.36.x, each one pushed the top bar down while the sidebar's logo band stayed at the top. Both bands read `SHELL_HEADER_BAND` so they are the same height with the same border, and the line they draw together still broke: with the French maintainership strip up, the two borders sat 66.5px apart at 768px and 61px apart at 1280px, and the full three-banner stack put them 184.5px apart. Above the row, every banner displaces both bands by the same amount and the seam cannot come apart however many paint. The stack comes OUT of the viewport budget rather than being added on top of it: the outer wrapper owns the `h-dvh`, the row below takes what the banners leave (`min-h-0 flex-1`), and `
` stays the one vertical scroller. Measured in `e2e/chrome-header-seam-banners.spec.ts` at both breakpoints. */}
{/* v1.4.43 QoL (M5) — `` paints only when `navigator.onLine === false`. Sits above the maintainership banner + top bar so the connection-status hint is always 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 error after the user already filled in a form. */} {demoMode ? : null}
{/* `min-w-0` lets the content column shrink below its children's intrinsic min width (e.g. the measurements table); without it the column widens past the viewport next to the sidebar and the whole page gains a horizontal scrollbar. Wide children scroll inside their own `overflow-x-auto` containers. */}
` (UI-STANDARDS §9 one-scroll-floor). // The class is not hypothetical and not confined to app code. A // Radix `` outside a `
` renders a hidden // `position:absolute` bubble input on the first pass and drops it // only once the button ref resolves, so every route carrying a // switch below the fold (/settings/modules is the long one) grew // a full-height document scrollbar for the entire pre-hydration // window. Anchoring the scroll container closes the escape route // for all of them at once instead of asking each new // absolutely-positioned child to remember its own wrapper. className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0" > {/* v1.4.33 IW9 — container normalised on `max-w-screen-xl` (1280 px) so dashboard / settings / admin all hit the same content frame. Pre-v1.4.33 the dashboard shell capped at `max-w-[76.8rem]` (1228 px) while the settings/admin shells used `max-w-screen-xl`, producing a 52 px lateral wobble on every route switch. Same audit note in `.planning/round-v1433-audit-polish.md` §4.2. v1.16.8 — the bottom padding clears the always-on Coach FAB (48 px button + its offset above the bottom-nav band / the desktop bottom-6 anchor), so the last line of content can always scroll out from under the floating button. */}
{outsideSharedRecord ? : children}
{/* The three mounts below hang off the shell rather than off a route, so they paint on every page the shell serves — including the unavailable panel, which exists to say a page is not part of what was shared. All three are owner-only surfaces, and all three are gated on `!inSharedRecord` for the same reason: Insights and the Coach were deliberately kept out of what sharing covers (`nav-model.ts` — the owner's consent for server-managed LLM egress is theirs, not transferable), and the tour walks an account through modules a delegate does not administer. Every route behind them resolves `requireAuth()` and refuses under a switch, so leaving them mounted offered a delegate a door onto seven modules that answer 403 — chat, conversation delete and rename, attachments, feedback, adopt, suggested actions, and a `POST /api/insights/coach/seen` fired by merely opening the drawer. Absent, not disabled: a greyed-out launcher would still say "this exists and does not work for you", and what is true is that it is not part of what this person was given. */} {!inSharedRecord && } {/* v1.18.6 — the module-tour launcher lives at the shell level so its overlay survives the cross-page `router.push`es the tour makes. It self-gates: it only auto-opens on the dashboard for a user who has not finished/dismissed the tour, and otherwise renders null. Stays out of demo mode (the tour PATCHes `/api/onboarding/tour`, which the proxy blocks for demo visitors). */} {!demoMode && !inSharedRecord && } {/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits `/api/insights/chat`, which is not in the proxy's `DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent got a raw 403. Demo is read-only by design; the FAB has no job here, so it's mounted only outside demo mode. */} {!demoMode && !inSharedRecord && } ); }