227 lines
6.6 KiB
TypeScript
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;
|
|
}
|