Files
stack/apps/web/src/lib/auth/auth-context.tsx
Jason Woltje 128431ba58
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
fix(api,web): separate workspace context from auth session (#551)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 15:14:29 +00:00

281 lines
8.4 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 { 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<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);
// 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 <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;
}