// AuthContext — exposes `apiFetch`, `useCurrentUser`, `googleLoginUrl` globally. // All fetch traffic to backend SHOULD go through `apiFetch` so cookies and CSRF // header are handled uniformly. Public endpoints can still use raw fetch but // will not get credentials injected. const _API_BASE = (typeof window !== 'undefined' && window.__API_BASE__) || 'http://localhost:8000'; const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); // CSRF token cached in module memory. Populated from /me response body // (synchronizer-token pattern). The backend's csrf cookie cannot be read // across subdomains, so we don't rely on document.cookie at all. let _csrfToken = null; function _setCsrfToken(t) { _csrfToken = t || null; } function _getCsrfToken() { return _csrfToken; } const _authListeners = new Set(); function _notifyAuthChange() { for (const fn of _authListeners) { try { fn(); } catch (_) {} } } async function apiFetch(path, opts = {}) { const url = path.startsWith('http') ? path : `${_API_BASE}${path}`; const method = (opts.method || 'GET').toUpperCase(); const headers = new Headers(opts.headers || {}); if (UNSAFE_METHODS.has(method)) { const csrf = _getCsrfToken(); if (csrf) headers.set('X-CSRF-Token', csrf); } if (opts.body && !headers.has('Content-Type') && typeof opts.body === 'string') { headers.set('Content-Type', 'application/json'); } const res = await fetch(url, { ...opts, method, headers, credentials: 'include', }); // 401 from /me is the discovery probe — getting 401 there just means // "not logged in", which is the expected state for anonymous visitors. // Notifying listeners would cause useCurrentUser.refresh to re-fire and // re-probe /me, creating an infinite 401 loop. Only notify for 401 from // protected endpoints (which indicates a session that died mid-flight). if (res.status === 401 && !path.startsWith('/me') && !path.startsWith('/auth/')) { _setCsrfToken(null); _notifyAuthChange(); } return res; } const googleLoginUrl = () => `${_API_BASE}/auth/google/start`; async function logout() { try { await apiFetch('/auth/logout', { method: 'POST' }); } finally { _setCsrfToken(null); _notifyAuthChange(); } } // React hook: load and expose current user from /me. Returns // { user, loading, refresh, logout, error } where user is null when not logged in. function useCurrentUser() { const [user, setUser] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const refresh = React.useCallback(async () => { setLoading(true); setError(null); try { const res = await apiFetch('/me'); if (res.status === 200) { const body = await res.json(); _setCsrfToken(body.csrf_token); setUser(body); } else if (res.status === 401) { _setCsrfToken(null); setUser(null); } else { setError(`HTTP ${res.status}`); setUser(null); } } catch (e) { setError(String(e)); setUser(null); } finally { setLoading(false); } }, []); React.useEffect(() => { refresh(); const listener = () => refresh(); _authListeners.add(listener); return () => _authListeners.delete(listener); }, [refresh]); const doLogout = React.useCallback(async () => { await logout(); setUser(null); }, []); return { user, loading, error, refresh, logout: doLogout }; } Object.assign(window, { apiFetch, useCurrentUser, googleLoginUrl, logout });