/** * Authentication context and provider. * * Manages the user's authenticated state and exposes `login`, `logout`, and * `isAuthenticated` through `useAuth()`. The session token is persisted in * `sessionStorage` so it survives page refreshes within the browser tab but * is automatically cleared when the tab is closed. */ import { createContext, useCallback, useContext, useMemo, useState, } from "react"; import * as authApi from "../api/auth"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface AuthState { token: string | null; expiresAt: string | null; } interface AuthContextValue { /** `true` when a valid session token is held in state. */ isAuthenticated: 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; } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- const AuthContext = createContext(null); const SESSION_KEY = "bangui_token"; const SESSION_EXPIRES_KEY = "bangui_expires_at"; // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- /** * Wraps the application and provides authentication state to all children. * * Place this inside `` and `` so all * descendants can call `useAuth()`. */ export function AuthProvider({ children, }: { children: React.ReactNode; }): JSX.Element { const [auth, setAuth] = useState(() => ({ token: sessionStorage.getItem(SESSION_KEY), expiresAt: sessionStorage.getItem(SESSION_EXPIRES_KEY), })); const isAuthenticated = useMemo(() => { if (!auth.token || !auth.expiresAt) return false; // Treat the session as expired if the expiry time has passed. return new Date(auth.expiresAt) > new Date(); }, [auth]); const login = useCallback(async (password: string): Promise => { const response = await authApi.login(password); sessionStorage.setItem(SESSION_KEY, response.token); sessionStorage.setItem(SESSION_EXPIRES_KEY, response.expires_at); setAuth({ token: response.token, expiresAt: response.expires_at }); }, []); 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(SESSION_KEY); sessionStorage.removeItem(SESSION_EXPIRES_KEY); setAuth({ token: null, expiresAt: null }); } }, []); const value = useMemo( () => ({ isAuthenticated, login, logout }), [isAuthenticated, login, logout], ); return {children}; } // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- /** * Access authentication state and actions. * * Must be called inside a component rendered within ``. * * @throws {Error} When called outside of ``. */ export function useAuth(): AuthContextValue { const ctx = useContext(AuthContext); if (ctx === null) { throw new Error("useAuth must be used within ."); } return ctx; }