"use client"; import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode, } from "react"; import type { AuthUser, AuthSession } from "@mosaic/shared"; import { apiGet, apiPost } from "../api/client"; import { IS_MOCK_AUTH_MODE } from "../config"; import { parseAuthError } from "./auth-errors"; /** * Error types for auth session checks */ export type AuthErrorType = "network" | "backend" | "session_expired" | null; /** Threshold in minutes before session expiry to start warning */ const SESSION_EXPIRY_WARNING_MINUTES = 5; /** Interval in milliseconds to check session expiry */ const SESSION_CHECK_INTERVAL_MS = 60_000; const MOCK_AUTH_USER: AuthUser = { id: "dev-user-local", email: "dev@localhost", name: "Local Dev User", }; interface AuthContextValue { user: AuthUser | null; isLoading: boolean; isAuthenticated: boolean; authError: AuthErrorType; sessionExpiring: boolean; sessionMinutesRemaining: number; signOut: () => Promise; refreshSession: () => Promise; } const AuthContext = createContext(undefined); /** * Classify an error into an {@link AuthErrorType} using the centralised * {@link parseAuthError} utility. * * Normal authentication failures (401 Unauthorized, session expired) return * `null` so the UI simply shows the logged-out state without a banner. * * Defaults unrecognised `Error` instances to `"backend"` rather than `null` * so that unexpected failures surface a "having trouble connecting" banner * instead of silently logging the user out. */ function classifyAuthError(error: unknown): AuthErrorType { const parsed = parseAuthError(error); if (parsed.code === "network_error") return "network"; if (parsed.code === "server_error") return "backend"; // Normal auth failures (not logged in, session expired) are not errors — // return null so the UI shows logged-out state without a banner if (parsed.code === "invalid_credentials" || parsed.code === "session_expired") return null; // For truly unrecognised errors, default to "backend" rather than null // (safer to show "having trouble connecting" than silently log out) if (error instanceof Error) return "backend"; return null; } /** * Log auth errors — always logs, including production. * Auth failures are operational issues, not debug noise. */ function logAuthError(message: string, error: unknown): void { console.error(`[Auth] ${message}:`, error); } export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element { if (IS_MOCK_AUTH_MODE) { return {children}; } return {children}; } function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Element { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [authError, setAuthError] = useState(null); const [sessionExpiring, setSessionExpiring] = useState(false); const [sessionMinutesRemaining, setSessionMinutesRemaining] = useState(0); const expiresAtRef = useRef(null); const checkSession = useCallback(async () => { try { const session = await apiGet("/auth/session"); setUser(session.user); setAuthError(null); // Track session expiry timestamp expiresAtRef.current = new Date(session.session.expiresAt); // Reset expiring state on successful session check setSessionExpiring(false); } catch (error) { const errorType = classifyAuthError(error); if (errorType) { logAuthError("Session check failed due to backend/network issue", error); } setAuthError(errorType); setUser(null); expiresAtRef.current = null; setSessionExpiring(false); } finally { setIsLoading(false); } }, []); const signOut = useCallback(async () => { try { await apiPost("/auth/sign-out"); } catch (error) { logAuthError("Sign out request did not complete", error); setAuthError(classifyAuthError(error) ?? "backend"); } finally { setUser(null); expiresAtRef.current = null; setSessionExpiring(false); } }, []); const refreshSession = useCallback(async () => { await checkSession(); }, [checkSession]); useEffect(() => { void checkSession(); }, [checkSession]); // Periodically check whether the session is approaching expiry useEffect((): (() => void) => { if (!user || !expiresAtRef.current) { return (): void => { /* no-op cleanup */ }; } const checkExpiry = (): void => { if (!expiresAtRef.current) return; const remainingMs = expiresAtRef.current.getTime() - Date.now(); const minutes = Math.ceil(remainingMs / 60_000); if (minutes <= 0) { // Session has expired — set explicit state so the UI can react setUser(null); setSessionExpiring(false); setSessionMinutesRemaining(0); expiresAtRef.current = null; setAuthError("session_expired"); } else if (minutes <= SESSION_EXPIRY_WARNING_MINUTES) { setSessionExpiring(true); setSessionMinutesRemaining(minutes); } else { setSessionExpiring(false); setSessionMinutesRemaining(minutes); } }; checkExpiry(); const interval = setInterval(checkExpiry, SESSION_CHECK_INTERVAL_MS); return (): void => { clearInterval(interval); }; }, [user]); const value: AuthContextValue = { user, isLoading, isAuthenticated: user !== null, authError, sessionExpiring, sessionMinutesRemaining, signOut, refreshSession, }; return {children}; } function MockAuthProvider({ children }: { children: ReactNode }): React.JSX.Element { const [user, setUser] = useState(MOCK_AUTH_USER); const signOut = useCallback((): Promise => { setUser(null); return Promise.resolve(); }, []); const refreshSession = useCallback((): Promise => { setUser(MOCK_AUTH_USER); return Promise.resolve(); }, []); const value: AuthContextValue = { user, isLoading: false, isAuthenticated: user !== null, authError: null, sessionExpiring: false, sessionMinutesRemaining: 0, signOut, refreshSession, }; return {children}; } export function useAuth(): AuthContextValue { const context = useContext(AuthContext); if (context === undefined) { throw new Error("useAuth must be used within AuthProvider"); } return context; }