/** * Axios instance shared by the whole app. * * Responsibilities: * - prefix every call with the API base URL; * - attach the bearer token; * - transparently refresh an expired access token exactly once per failure, * queueing any parallel requests so a burst of 401s triggers one refresh; * - normalise the backend's error envelope into a plain `Error`. * * Tokens live in `sessionStorage`: they disappear when the tab is closed, and * unlike a cookie they are never attached to cross-site requests, so the API * needs no CSRF protection for its JSON endpoints. */ import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; import type { ApiError } from '@/types'; const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'; const ACCESS_KEY = 'lks.access'; const REFRESH_KEY = 'lks.refresh'; export const tokenStore = { get access(): string | null { return sessionStorage.getItem(ACCESS_KEY); }, get refresh(): string | null { return sessionStorage.getItem(REFRESH_KEY); }, save(access: string, refresh?: string): void { sessionStorage.setItem(ACCESS_KEY, access); if (refresh) sessionStorage.setItem(REFRESH_KEY, refresh); }, clear(): void { sessionStorage.removeItem(ACCESS_KEY); sessionStorage.removeItem(REFRESH_KEY); }, }; export const api: AxiosInstance = axios.create({ baseURL: BASE_URL, timeout: 15000, headers: { 'Content-Type': 'application/json' }, }); api.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = tokenStore.access; if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); type Waiter = (token: string | null) => void; let refreshing = false; let waiters: Waiter[] = []; function notify(token: string | null): void { waiters.forEach((resolve) => resolve(token)); waiters = []; } async function refreshAccessToken(): Promise { const refresh = tokenStore.refresh; if (!refresh) return null; try { const { data } = await axios.post<{ access: string; refresh?: string }>( `${BASE_URL}/auth/refresh/`, { refresh }, { headers: { 'Content-Type': 'application/json' } }, ); tokenStore.save(data.access, data.refresh); return data.access; } catch { tokenStore.clear(); return null; } } api.interceptors.response.use( (response) => response, async (error: AxiosError) => { const original = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined; if (error.response?.status === 401 && original && !original._retried) { original._retried = true; if (refreshing) { const token = await new Promise((resolve) => waiters.push(resolve)); if (!token) return Promise.reject(toError(error)); original.headers.Authorization = `Bearer ${token}`; return api(original); } refreshing = true; const token = await refreshAccessToken(); refreshing = false; notify(token); if (token) { original.headers.Authorization = `Bearer ${token}`; return api(original); } window.dispatchEvent(new CustomEvent('auth:expired')); } return Promise.reject(toError(error)); }, ); export function toError(error: unknown): Error { if (axios.isAxiosError(error)) { const payload = error.response?.data; if (payload?.error) { const enriched = new Error(payload.error.message); enriched.name = payload.error.code; return enriched; } if (error.code === 'ECONNABORTED') return new Error('The request timed out. Please retry.'); return new Error(error.message); } return error instanceof Error ? error : new Error('Unexpected error'); }