"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 { fetchUserWorkspaces } from "../api/workspaces"; 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; /** * localStorage key for the active workspace ID. * Must match the WORKSPACE_KEY constant in useLayout.ts and the key read * by apiRequest in client.ts. */ const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id"; /** * Persist the workspace ID to localStorage so it is available to * useWorkspaceId and apiRequest on the next render / request cycle. * Silently ignores localStorage errors (private browsing, storage full). */ function persistWorkspaceId(workspaceId: string | undefined): void { if (typeof window === "undefined") return; try { if (workspaceId) { localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId); } } catch { // localStorage unavailable — not fatal } } /** * Remove the workspace ID from localStorage on sign-out so stale workspace * context is not sent on subsequent unauthenticated requests. */ function clearWorkspaceId(): void { if (typeof window === "undefined") return; try { localStorage.removeItem(WORKSPACE_STORAGE_KEY); } catch { // localStorage unavailable — not fatal } } 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); // Fetch the user's workspace memberships and persist the default. // Workspace context is an application concern, not an auth concern — // BetterAuth does not return workspace fields on the session user. try { const workspaces = await fetchUserWorkspaces(); const defaultWorkspace = workspaces[0]; if (defaultWorkspace) { persistWorkspaceId(defaultWorkspace.id); } } catch (wsError) { logAuthError("Failed to fetch workspaces after session check", wsError); } // 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); // Clear persisted workspace ID so stale context is not sent on // subsequent unauthenticated API requests. clearWorkspaceId(); } }, []); 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; }