import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, } from "axios"; import { clearAuth, getAccessToken, getRefreshToken, setTokens, } from "@/store/authStore"; import type { TokenResponse } from "@/types/auth.types"; // All calls go through "/api"; nginx (prod) and the Vite dev proxy forward it to the API. export const api: AxiosInstance = axios.create({ baseURL: "/api", headers: { "Content-Type": "application/json" }, }); // A bare client for the refresh call so it never triggers the response interceptor (no loops). const refreshClient = axios.create({ baseURL: "/api", headers: { "Content-Type": "application/json" }, }); api.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // --- Silent refresh handling ------------------------------------------------- // While a refresh is in flight, queue concurrent 401s and replay them once done. let isRefreshing = false; let pendingQueue: Array<(token: string | null) => void> = []; function flushQueue(token: string | null): void { pendingQueue.forEach((resolve) => resolve(token)); pendingQueue = []; } function redirectToLogin(): void { clearAuth(); const path = window.location.pathname; if (path !== "/login" && path !== "/register") { window.location.assign("/login"); } } async function performRefresh(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) { return null; } try { const { data } = await refreshClient.post("/auth/refresh", { refreshToken, }); setTokens(data); return data.accessToken; } catch { return null; } } api.interceptors.response.use( (response) => response, async (error: AxiosError) => { const original = error.config as | (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined; const status = error.response?.status; const isAuthEndpoint = original?.url?.includes("/auth/login") || original?.url?.includes("/auth/register") || original?.url?.includes("/auth/refresh"); if (status !== 401 || !original || original._retry || isAuthEndpoint) { return Promise.reject(error); } original._retry = true; if (isRefreshing) { // Wait for the in-flight refresh, then retry (or fail) with its result. return new Promise((resolve, reject) => { pendingQueue.push((token) => { if (!token) { reject(error); return; } original.headers.Authorization = `Bearer ${token}`; resolve(api(original)); }); }); } isRefreshing = true; const newToken = await performRefresh(); isRefreshing = false; flushQueue(newToken); if (!newToken) { redirectToLogin(); return Promise.reject(error); } original.headers.Authorization = `Bearer ${newToken}`; return api(original); }, ); /** Extracts a user-facing message from an API error response. */ const GENERIC_API_TITLES = new Set([ "An unexpected error occurred.", "Internal Server Error", ]); export function extractApiError(error: unknown, fallback = "Something went wrong."): string { if (axios.isAxiosError(error)) { const data = error.response?.data as { error?: string; title?: string } | undefined; if (data?.error) return data.error; if (data?.title && !GENERIC_API_TITLES.has(data.title)) return data.title; return fallback; } if (error instanceof Error) { return error.message; } return fallback; }