/** * Authentication context and provider. * * Manages the user's authenticated state and exposes `login`, `logout`, and * `isAuthenticated` through `useAuth()`. * * The authentication model is **cookie-based**: the backend sets a session * cookie (`bangui_session`) on successful login, and the frontend automatically * includes it in all requests via `credentials: "include"`. The backend is the * authority on session validity; the frontend is authenticated when the backend * accepts the request (returns 2xx) and not authenticated when the backend * rejects it (returns 401/403). * * On app mount, the provider validates the cached session state with the backend * by calling `GET /api/auth/session`. While this check is in flight, a loading * indicator is shown. If the backend returns 401/403, the session is cleared. * If a network error occurs, the user is not logged out (the backend may be * temporarily unreachable). * * The `isAuthenticated` state persists in `sessionStorage` to survive page * refreshes within the browser tab but is cleared on tab close. The session * cookie itself persists according to the backend's cookie settings. * * **Cross-Tab Synchronization:** * The provider uses BroadcastChannel API to synchronize logout events across * tabs in real-time. On logout, a message is broadcast to all other tabs so they * immediately reflect the logout state without requiring a page refresh or API call. * If the browser does not support BroadcastChannel, the storage event listener * serves as a fallback (though it only fires when storage is changed in another tab). * * **Auth Error Handling & Deduplication:** * AuthProvider registers two auth error handlers to ensure that 401/403 errors * are never silently swallowed: * - `setUnauthorizedHandler()` in `api/client.ts` — handles auth errors from the * API client layer before they reach hooks * - `setAuthErrorHandler()` in `utils/fetchError.ts` — handles auth errors that * reach hooks and ensures deterministic error handling with fallback logging * * A module-level `isLoggingOut` flag in each file prevents double-firing: the * first handler to fire sets its flag, and the second checks it before invoking * its handler. Both flags are reset in `handleSessionExpired()` so future 401s * can trigger the handlers again after the user reaches the login page. * * This dual-handler approach with deduplication ensures auth errors are caught * at multiple layers, preventing silent error loss regardless of where the * error occurs, while avoiding the React state mutation race caused by * concurrent logout dispatches. */ import React, { createContext, useCallback, useEffect, useMemo, useState, } from "react"; import { useNavigate } from "react-router-dom"; import * as authApi from "../api/auth"; import { ApiError, setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client"; import { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError"; import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants"; import { SessionValidationLoading } from "../components/SessionValidationLoading"; import { useSessionValidation } from "../hooks/useSessionValidation"; // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- export interface AuthContextValue { /** `true` when the backend considers the session valid. */ isAuthenticated: boolean; /** `true` while the session is being validated on app mount. */ isValidating: boolean; /** * Authenticate with the master password. * Throws an `ApiError` on failure. */ login: (password: string) => Promise; /** Revoke the current session and clear local state. */ logout: () => Promise; } export const AuthContext = createContext(null); // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const BROADCAST_CHANNEL_NAME = "bangui_auth" as const; const BROADCAST_MESSAGE_TYPE_LOGOUT = "logout" as const; // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- /** * Wraps the application and provides authentication state to all children. * * Place this inside `` and `` so all * descendants can call `useAuth()`. * * On mount, validates the cached session with the backend. While validation * is in progress, a loading indicator is shown. If validation fails with 401, * the user is logged out. Network errors do not cause logout. * * Listens for cross-tab logout events via BroadcastChannel (or storage events * as fallback) to immediately reflect logout in other tabs. */ export function AuthProvider({ children, }: { children: React.ReactNode; }): React.JSX.Element { const [isAuthenticated, setIsAuthenticated] = useState(() => { const stored = sessionStorage.getItem(STORAGE_KEY_AUTHENTICATED); return stored === "true"; }); const [isValidating, setIsValidating] = useState(isAuthenticated); const navigate = useNavigate(); const handleSessionExpired = useCallback((): void => { sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED); setIsAuthenticated(false); // Reset deduplication flags so future 401s can trigger handlers again. resetLogoutState(); resetFetchErrorLogoutState(); navigate("/login", { replace: true }); }, [navigate]); const handleSessionValid = useCallback((): void => { // Session is already set from sessionStorage; no action needed. }, []); const handleValidationError = useCallback( (error: Error): void => { // Suppress noisy warning for 5xx gateway errors (e.g. 502 Bad Gateway) // during startup — these are server-side issues, not network issues. if (error instanceof ApiError && error.status >= 500) { return; } // Network error — log but don't logout. console.warn("Session validation network error:", error); }, [], ); // Handle storage events from other tabs (fallback sync mechanism) useEffect((): (() => void) => { const handleStorageChange = (event: StorageEvent): void => { // Only respond to changes in our authentication key if (event.key === STORAGE_KEY_AUTHENTICATED) { // If the storage was cleared in another tab, logout this tab if (event.newValue === null) { handleSessionExpired(); } } }; window.addEventListener("storage", handleStorageChange); return (): void => { window.removeEventListener("storage", handleStorageChange); }; }, [handleSessionExpired]); // Handle BroadcastChannel messages for real-time cross-tab sync useEffect((): (() => void) => { let channel: BroadcastChannel | null = null; try { channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME); channel.onmessage = (event: MessageEvent): void => { if (event.data?.type === BROADCAST_MESSAGE_TYPE_LOGOUT) { handleSessionExpired(); } }; } catch { // BroadcastChannel not supported; storage event listener serves as fallback } return (): void => { if (channel) { channel.close(); } }; }, [handleSessionExpired]); useEffect((): (() => void) => { setUnauthorizedHandler((): void => { handleSessionExpired(); }); setAuthErrorHandler((): void => { handleSessionExpired(); }); return (): void => { setUnauthorizedHandler(null); setAuthErrorHandler(null); }; }, [handleSessionExpired]); const validationResult = useSessionValidation( handleSessionValid, handleSessionExpired, handleValidationError, ); useEffect(() => { setIsValidating(validationResult.isLoading); }, [validationResult.isLoading]); const login = useCallback(async (password: string): Promise => { await authApi.login(password); sessionStorage.setItem(STORAGE_KEY_AUTHENTICATED, "true"); setIsAuthenticated(true); }, []); const logout = useCallback(async (): Promise => { try { await authApi.logout(); } finally { // Always clear local state even if the API call fails (e.g. expired session). sessionStorage.removeItem(STORAGE_KEY_AUTHENTICATED); setIsAuthenticated(false); clearSessionCorrelationId(); // Broadcast logout event to other tabs for immediate sync try { const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME); channel.postMessage({ type: BROADCAST_MESSAGE_TYPE_LOGOUT }); channel.close(); } catch { // BroadcastChannel not supported; storage event will fire on other tabs automatically } } }, []); const value = useMemo( () => ({ isAuthenticated, isValidating, login, logout }), [isAuthenticated, isValidating, login, logout], ); // Show loading spinner while validating session on mount. if (isValidating && isAuthenticated) { return ; } return {children}; }