Files
stack/apps/web/src/lib/auth/auth-context.tsx
Jason Woltje af299abdaf
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
debug(auth): log session cookie source
2026-02-18 21:36:01 -06:00

227 lines
6.6 KiB
TypeScript

"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<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | undefined>(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 <MockAuthProvider>{children}</MockAuthProvider>;
}
return <RealAuthProvider>{children}</RealAuthProvider>;
}
function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authError, setAuthError] = useState<AuthErrorType>(null);
const [sessionExpiring, setSessionExpiring] = useState(false);
const [sessionMinutesRemaining, setSessionMinutesRemaining] = useState(0);
const expiresAtRef = useRef<Date | null>(null);
const checkSession = useCallback(async () => {
try {
const session = await apiGet<AuthSession>("/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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
function MockAuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [user, setUser] = useState<AuthUser | null>(MOCK_AUTH_USER);
const signOut = useCallback((): Promise<void> => {
setUser(null);
return Promise.resolve();
}, []);
const refreshSession = useCallback((): Promise<void> => {
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}