import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios"; interface RetryConfig extends InternalAxiosRequestConfig { _retry?: boolean; } const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000", headers: { "Content-Type": "application/json", }, }); // ── Token management ────────────────────────────────────────────────── function getAccessToken(): string | null { return localStorage.getItem("access_token"); } function getRefreshToken(): string | null { return localStorage.getItem("refresh_token"); } function setTokens(access: string, refresh: string): void { localStorage.setItem("access_token", access); localStorage.setItem("refresh_token", refresh); } function clearTokens(): void { localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); } // ── Request interceptor: attach access token ────────────────────────── apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = getAccessToken(); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // ── Response interceptor: auto-refresh on 401, retry once ───────────── let isRefreshing = false; let failedQueue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void; }> = []; function processQueue(error: unknown, token: string | null = null): void { failedQueue.forEach((prom) => { if (error) { prom.reject(error); } else if (token) { prom.resolve(token); } }); failedQueue = []; } apiClient.interceptors.response.use( (response: AxiosResponse) => response, async (error: AxiosError) => { const originalRequest = error.config as RetryConfig; if (!originalRequest) { return Promise.reject(error); } // Only handle 401s that aren't already refresh/login/register/logout attempts if ( error.response?.status !== 401 || originalRequest._retry || originalRequest.url?.includes("/api/auth/token/refresh/") || originalRequest.url?.includes("/api/auth/login/") || originalRequest.url?.includes("/api/auth/register/") || originalRequest.url?.includes("/api/auth/logout/") ) { return Promise.reject(error); } if (isRefreshing) { // Queue this request until the refresh completes return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then((token) => { originalRequest.headers!.Authorization = `Bearer ${token}`; return apiClient(originalRequest); }); } originalRequest._retry = true; isRefreshing = true; const refreshToken = getRefreshToken(); if (!refreshToken) { isRefreshing = false; clearTokens(); window.location.href = "/login"; return Promise.reject(error); } try { const response = await axios.post( `${apiClient.defaults.baseURL}/api/auth/token/refresh/`, { refresh: refreshToken } ); const newAccessToken = response.data.access; const newRefreshToken = response.data.refresh; setTokens(newAccessToken, newRefreshToken); processQueue(null, newAccessToken); originalRequest.headers!.Authorization = `Bearer ${newAccessToken}`; return apiClient(originalRequest); } catch (refreshError) { processQueue(refreshError, null); clearTokens(); window.location.href = "/login"; return Promise.reject(refreshError); } finally { isRefreshing = false; } } ); // ── Error extraction from Axios responses ───────────────────────────── /** * Extract a human-readable error message from an AxiosError. * Backend DRF errors can be: * - {"field": ["message"]} (field-level) * - {"non_field_errors": ["message"]} (general) * - {"detail": "message"} (list/generic) * - string (unexpected shape) */ export function extractErrorMessage(err: unknown): string { if (err instanceof AxiosError && err.response?.data) { const data = err.response.data as Record; // DRF non_field_errors if ( Array.isArray(data.non_field_errors) && data.non_field_errors.length > 0 ) { return String(data.non_field_errors[0]); } // DRF detail (e.g., 401 Unauthorized) if (typeof data.detail === "string") { return data.detail; } // Field-level errors — pick the first one for (const key of Object.keys(data)) { const val = data[key]; if (Array.isArray(val) && val.length > 0) { return String(val[0]); } if (typeof val === "string") { return val; } } // Fallback: raw string if (typeof data === "string") return data; } // Generic error fallback if (err instanceof Error) return err.message; return "An unexpected error occurred. Please try again."; } // ── Types ────────────────────────────────────────────────────────────── export interface RegisterPayload { email: string; password: string; password_confirm: string; first_name?: string; last_name?: string; } export interface LoginPayload { email: string; password: string; } export interface UserProfile { id: number; first_name: string; last_name: string; } export interface LoginResponse { user: UserProfile; access: string; refresh: string; } // ── API functions ────────────────────────────────────────────────────── export function registerUser(payload: RegisterPayload): Promise { return apiClient .post("/api/auth/register/", payload) .then((res) => res.data); } export function loginUser(payload: LoginPayload): Promise { return apiClient .post("/api/auth/login/", payload) .then((res) => res.data); } export function getProfile(): Promise { return apiClient .get("/api/auth/me/") .then((res) => res.data); } export function logoutUser(refreshToken: string): Promise { return apiClient .post("/api/auth/logout/", { refresh: refreshToken }) .then(() => {}); } export function refreshAccessToken( refreshToken: string ): Promise<{ access: string; refresh: string }> { return apiClient .post<{ access: string; refresh: string }>( "/api/auth/token/refresh/", { refresh: refreshToken } ) .then((res) => res.data); } export { apiClient, getAccessToken, getRefreshToken, setTokens, clearTokens, };