/** * Client-side session gate for an authenticated app shell. * * Wrap a template's private app shell with so a logged-out * visitor is sent to the framework sign-in page instead of being left staring * at an infinite loading spinner. * * Why this exists in addition to the server-side auth guard (`runAuthGuard`, * which serves the onboarding/sign-in HTML for unauthenticated requests): * the server guard only protects requests that actually reach the Nitro * function. A statically-served / CDN-cached SPA shell, or a client-side * (React Router) navigation made after the session expired, never re-hits the * guard — so the app boots with no session, every data query 401s, and the UI * sticks on its loading state forever. This component closes that gap by * resolving the session on the client and redirecting when there is none. * * Place it INSIDE your providers (so the fallback is themed) but AROUND the * routed app layout: * * * * * * * * Templates with public/anonymous routes (share pages, embeds) must NOT wrap * their whole app — gate only the private subtree, or pass `bypass` for the * surfaces that authenticate by another mechanism. */ import React, { useEffect, useRef } from "react"; import { signInJourney } from "../shared/sign-in-journey.js"; import { agentNativePath, appBasePath } from "./api-path.js"; import { DefaultSpinner } from "./DefaultSpinner.js"; import { useSession } from "./use-session.js"; /** * The sign-in journey for the browser's current location. * * `basePath` comes from `appBasePath()` and NEVER from the continuation — * base-path containment is the only control that stops an unsigned path-only * continuation from redirecting to a sibling app on a shared workspace host. */ function currentJourney(returnTo?: string) { const { pathname, search, hash } = window.location; return signInJourney({ at: returnTo ?? pathname + search + hash, continuation: new URLSearchParams(search).get("c"), legacyReturn: new URLSearchParams(search).get("return"), basePath: appBasePath(), }); } export interface RequireSessionProps { children: React.ReactNode; /** * Rendered while the session is being resolved and while a redirect is in * flight. Defaults to the framework ``. */ fallback?: React.ReactNode; /** * When true (default), unauthenticated visitors are redirected to the * framework sign-in entry point (`/_agent-native/sign-in`) carrying a `c` * continuation for the current URL — so they land back here once signed in. * When false, `signedOut` is rendered instead and no navigation happens. */ redirect?: boolean; /** * Rendered for unauthenticated visitors when `redirect` is false. Ignored * when `redirect` is true. */ signedOut?: React.ReactNode; /** * Skip the gate entirely and always render children. Use for surfaces that * authenticate by another mechanism (e.g. an embed/popout iframe carrying * its own token) so they are never bounced to the sign-in page. */ bypass?: boolean; } /** * Build the framework sign-in URL that returns the visitor to where they are * now (or to `returnTo`). Emits the opaque `?c=` continuation. * * Returns the bare sign-in path — no continuation — when the browser is * already at an auth entry path, because there is no such thing as signing in * from the sign-in page. Callers that navigate must use `signInJourney` * directly and honour its `signInHref: null`. */ export function buildSignInReturnHref(opts?: { returnTo?: string }): string { const base = agentNativePath("/_agent-native/sign-in"); if (typeof window === "undefined") return base; return currentJourney(opts?.returnTo).signInHref ?? base; } export function RequireSession({ children, fallback, redirect = true, signedOut, bypass = false, }: RequireSessionProps) { if (bypass) return <>{children}; return ( {children} ); } function ResolvedSessionGate({ children, fallback, redirect = true, signedOut, }: Omit) { const { session, isLoading } = useSession(); // Guard against firing the redirect more than once (effect re-runs, React // StrictMode double-invoke) — a second navigation while the first is in // flight is harmless but noisy. const redirectedRef = useRef(false); const mustRedirect = !isLoading && !session && redirect; useEffect(() => { if (!mustRedirect) return; if (redirectedRef.current) return; if (typeof window === "undefined") return; // `null` means the browser is already at an auth entry path — the sign-in // page is the framework's job, not the gate's. This is the only thing // standing between here and a same-URL replace loop, so it must stay a // null check and never gain a fallback. const { signInHref } = currentJourney(); if (!signInHref) return; redirectedRef.current = true; // `replace` (not `assign`) so the dead authenticated URL doesn't land in // history — pressing Back after signing in shouldn't bounce here again. window.location.replace(signInHref); }, [mustRedirect]); // Still resolving, or redirect already in flight: show the loading fallback // rather than flashing app chrome the visitor can't use. if (isLoading) return <>{fallback ?? }; if (!session) { if (redirect) return <>{fallback ?? }; return <>{signedOut ?? null}; } return <>{children}; }