/** * Auth-aware fetch wrapper. * * Every authenticated backend call in the SPA goes through `authedFetch`. * Responsibilities: * * 1. Attach `credentials: "include"` so the `cyclone_session` HttpOnly * cookie rides along on cross-origin XHR calls. * 2. Tag every request with `Accept: application/json` by default so * the FastAPI backend knows to return its structured error shape * (`{"error": "...", "detail": "..."}`) on failures. * 3. On a 401 from anything OTHER than the auth endpoints themselves, * redirect to `/login?next=` so the operator sees a real * sign-in screen instead of an infinite stream of failing queries. * 401 from `/api/auth/*` is the normal "bad password" path — let * the caller handle it. * 4. On any other non-2xx, parse the JSON body and throw an `ApiError` * carrying `status`, the backend's `error` code, and the `detail` * string. The Login page and hooks both branch on `.code`. * 5. On 204, return `undefined` (so callers can `await` without * blowing up on `res.json()` of an empty body). * 6. Otherwise return the parsed JSON. * * `authApi` is the typed wrapper around the three auth endpoints * (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`). */ const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""; function joinUrl(path: string): string { if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`; return path; } /** * Error thrown for any non-2xx `authedFetch` response. Carries the HTTP * `status`, the backend's `error` code (so callers can branch on * `err.code === "invalid_credentials"`, etc.), and the optional `detail` * string for surfacing in toasts. * * Distinct from the `ApiError` in `src/lib/api.ts` — that one only * carries `status` and is used by the existing pages; this one is the * richer auth-aware variant that the Login page + hooks depend on. */ export class ApiError extends Error { status: number; code: string; detail?: string; constructor(status: number, code: string, detail?: string) { super(detail ?? code); this.name = "ApiError"; this.status = status; this.code = code; this.detail = detail; } } function redirectToLogin() { const next = encodeURIComponent(window.location.pathname + window.location.search); window.location.href = `/login?next=${next}`; } export async function authedFetch( path: string, init?: RequestInit ): Promise { const res = await fetch(joinUrl(path), { credentials: "include", headers: { Accept: "application/json", ...(init?.headers ?? {}) }, ...init, }); if (res.status === 401 && !path.startsWith("/api/auth/")) { redirectToLogin(); throw new ApiError(401, "session_expired"); } if (!res.ok) { let body: any = null; try { body = await res.json(); } catch { /* no body */ } const code = body?.error ?? "error"; const detail = body?.detail ?? res.statusText; throw new ApiError(res.status, code, detail); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } // --------------------------------------------------------------------------- // Auth-specific endpoints. `login` and `logout` use raw `fetch` because // they bypass the 401-redirect behavior on purpose — a 401 from // /api/auth/login is "wrong password", not "session expired". // --------------------------------------------------------------------------- export const authApi = { async login(username: string, password: string) { const res = await fetch(joinUrl("/api/auth/login"), { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ username, password }), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new ApiError( res.status, body.error ?? "error", body.detail ?? res.statusText ); } return res.json(); }, async me() { return authedFetch("/api/auth/me"); }, async logout() { // Fire-and-forget. A network error here is fine — the server has // already cleared the cookie or the operator is signing out because // they're about to be disconnected. Either way, the client should // drop the user into the unauthenticated state. await fetch(joinUrl("/api/auth/logout"), { method: "POST", credentials: "include", }).catch(() => undefined); }, };