/* ============================================================ apiClient.js — the one HTTP entry point. Attaches the bearer token, renews proactively inside the skew window, and retries exactly once on a 401. Never loops: if the retry also 401s, the session is over and the expired handler fires. ============================================================ */ import { ApiError, parseDetail, SessionExpiredError } from './errors' import { getAccessToken, isExpiring } from './tokenStore' import { refreshSession } from './refresh' const API_BASE = import.meta.env.VITE_API_BASE ?? '' // AuthProvider registers this so the module never has to import the router. let onSessionExpired = () => {} export function setSessionExpiredHandler(fn) { onSessionExpired = fn } function buildUrl(path, params) { const url = new URL(`${API_BASE}${path}`, window.location.origin) if (params) { for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v) } } return url.toString() } /** * @param {object} opts * @param {boolean} [opts.auth=true] attach the session bearer token * @param {string} [opts.token] an explicit token (the password-reset flow); * suppresses all refresh behaviour */ export async function request( path, { method = 'GET', body, params, auth = true, token, signal } = {}, ) { // PROACTIVE renewal. Coalesced by single-flight, so a screen firing six // queries at once on an expiring token still triggers exactly one refresh. if (auth && !token && isExpiring()) { try { await refreshSession() } catch { // Fall through — the 401 path below makes the final call. This keeps a // transient network blip from logging the user out. } } // Multipart uploads pass a FormData body. Content-Type is deliberately NOT // set for those: the browser has to write it itself so the generated boundary // token ends up in the header, and a hand-set value strips it and the server // reads an unparseable body. FormData is replayable, so the 401 retry below // can re-send the same object. const multipart = typeof FormData !== 'undefined' && body instanceof FormData const send = async () => { const headers = { Accept: 'application/json' } if (body != null && !multipart) headers['Content-Type'] = 'application/json' const bearer = token ?? (auth ? getAccessToken() : null) if (bearer) headers.Authorization = `Bearer ${bearer}` return fetch(buildUrl(path, params), { method, headers, signal, body: body == null ? undefined : multipart ? body : JSON.stringify(body), }) } let res try { res = await send() } catch (err) { if (err?.name === 'AbortError') throw err throw new ApiError('Unable to reach the server. Check your connection.', 0, null) } // REACTIVE renewal — exactly one retry. if (res.status === 401 && auth && !token) { try { await refreshSession() } catch (err) { if (err instanceof SessionExpiredError) onSessionExpired() throw err } res = await send() if (res.status === 401) { // A fresh access token still 401s: the user was deactivated or soft-deleted // server-side (get_current_user rejects both). Signing them out is correct. onSessionExpired() throw new ApiError('Session expired', 401, null) } } let data = null const text = await res.text() if (text) { try { data = JSON.parse(text) } catch { data = null } } if (!res.ok) { throw new ApiError( parseDetail(data?.detail) || res.statusText || 'Request failed', res.status, data, ) } return data } /** * Authenticated file download. Document routes return a binary body, so they * cannot go through `request()` (that always JSON-parses). Same bearer / * refresh behaviour as `request`; 404s stay 404 (the download route never 403s). */ export async function downloadFile(path, { params, auth = true, filename } = {}) { if (auth && isExpiring()) { try { await refreshSession() } catch { /* fall through — the 401 path below makes the final call */ } } const send = async () => { const headers = { Accept: '*/*' } const bearer = auth ? getAccessToken() : null if (bearer) headers.Authorization = `Bearer ${bearer}` return fetch(buildUrl(path, params), { method: 'GET', headers }) } let res try { res = await send() } catch (err) { if (err?.name === 'AbortError') throw err throw new ApiError('Unable to reach the server. Check your connection.', 0, null) } if (res.status === 401 && auth) { try { await refreshSession() } catch (err) { if (err instanceof SessionExpiredError) onSessionExpired() throw err } res = await send() if (res.status === 401) { onSessionExpired() throw new ApiError('Session expired', 401, null) } } if (!res.ok) { let data = null const text = await res.text() if (text) { try { data = JSON.parse(text) } catch { data = null } } throw new ApiError( parseDetail(data?.detail) || res.statusText || 'Download failed', res.status, data, ) } const blob = await res.blob() const fromHeader = filenameFromDisposition(res.headers.get('Content-Disposition')) const name = filename || fromHeader || 'download' const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = name document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } /** * Authenticated binary GET returning an object URL for /