import { createContext, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { RefreshCw, TriangleAlert } from 'lucide-react'; import { Button, Card, Spinner } from './components'; import { clearLegacyStoredApiKey, getAuthConfig, getAuthSession, type AuthConfig, type AuthSession } from './api'; import { LoginScreen } from './screens/LoginScreen'; import { SetupScreen } from './screens/SetupScreen'; // The boot gate (#295). It wraps OUTSIDE the shell/router, so Login and Setup mint no URLs and a // deep link survives an intervening sign-in. On mount it asks the PUBLIC `/api/v1/auth/config`, then (unless // setup is required) `/api/v1/auth/session`, and renders Setup / Login / the app accordingly. A config-fetch // failure lands on an explicit error state with Retry — a keyless, sessionless browser must ALWAYS be able // to reach the login form, never a blank screen. export interface AuthContextValue { username: string | null; method: string | null; /** Return to the login screen after the caller has POSTed `/api/v1/auth/logout`. */ signOut: () => void; /** Flip the gate to the login screen (e.g. the 401 banner's "Sign in"). */ requireLogin: () => void; } // A safe default so consumers (App, UserMenu, UnauthorizedBanner) render without a provider — e.g. // `App.test.tsx` renders directly. AuthGate publishes the real value only in the `ready` state. // Colocated with the gate that owns it (per the #295 plan); the fast-refresh rule is disabled to allow // this non-component export alongside AuthGate, matching LocalLibraryEditScreen's established pattern. // eslint-disable-next-line react-refresh/only-export-components export const AuthContext = createContext({ username: null, method: null, signOut: () => {}, requireLogin: () => {} }); type GateState = | { status: 'checking' } | { status: 'error' } | { status: 'setup'; config: AuthConfig } | { status: 'login'; config: AuthConfig } | { status: 'ready'; session: AuthSession }; export function AuthGate({ children }: { children: ReactNode }) { const [state, setState] = useState({ status: 'checking' }); const activeRef = useRef(true); const seqRef = useRef(0); const configRef = useRef(null); const clearedRef = useRef(false); useEffect(() => { // Mount/unmount tracker — guards against the StrictMode double-mount and a slow fetch resolving // after unmount (spa-conventions §3). Re-arm on (re)mount so the second StrictMode pass is live. activeRef.current = true; return () => { activeRef.current = false; }; }, []); // Runs the boot fetch, setting state ONLY in the async completions (never synchronously) so it's safe // to invoke from the mount effect without tripping the react-hooks "no set-state-in-effect" rule (§3): // the initial `checking` state is the useState default, so the effect path needs no synchronous reset. const runGate = useCallback(() => { const seq = ++seqRef.current; getAuthConfig() .then((config) => { if (!activeRef.current || seq !== seqRef.current) { return undefined; } configRef.current = config; if (config.setupRequired) { setState({ status: 'setup', config }); return undefined; } return getAuthSession().then((session) => { if (!activeRef.current || seq !== seqRef.current) { return; } if (session.authenticated) { setState({ status: 'ready', session }); } else { setState({ status: 'login', config }); } }); }) .catch(() => { if (!activeRef.current || seq !== seqRef.current) { return; } setState({ status: 'error' }); }); }, []); useEffect(() => { runGate(); }, [runGate]); // Re-run the gate from an event handler (Retry / recovery), resetting to the checking state first. const restart = useCallback(() => { setState({ status: 'checking' }); runGate(); }, [runGate]); useEffect(() => { // Reaching the app once means any pre-session `ctv-api-key` is now dead — clear it a single time. if (state.status === 'ready' && !clearedRef.current) { clearedRef.current = true; clearLegacyStoredApiKey(); } }, [state.status]); const onAuthenticated = useCallback((session: AuthSession) => { setState({ status: 'ready', session }); }, []); const requireLogin = useCallback(() => { const config = configRef.current; if (config) { setState({ status: 'login', config }); } else { // No config cached (shouldn't happen post-boot) — re-run the whole gate to recover safely. restart(); } }, [restart]); if (state.status === 'checking') { return (
); } if (state.status === 'error') { return (
); } if (state.status === 'setup') { return ; } if (state.status === 'login') { return ; } return ( {children} ); }