import { AxiosError, AxiosResponse, InternalAxiosRequestConfig, } from "axios"; import Cookies from "js-cookie"; import { toast } from "react-toastify"; import { getLogoutCallback } from "../contexts/User.ctx"; import { API_ENDPOINTS, api } from "../lib/api"; import { COOKIE_KEYS } from "../lib/shared/cookies"; const SIGN_IN_PATH = "/auth/sign-in"; type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean }; interface TokenPair { access_token: string; refresh_token: string; } interface ApiErrorData { message?: string; error?: { code?: string; message?: string; details?: string; }; } interface QueuedRequest { resolve: (token: string) => void; reject: (reason?: unknown) => void; } // --------------------------------------------------------------------------- // Refresh-token queue: requests that arrive mid-refresh wait here and replay // once a fresh access token is available (or fail together if refresh fails). // --------------------------------------------------------------------------- let isRefreshing = false; let failedQueue: QueuedRequest[] = []; const processQueue = (error: unknown, token: string | null = null) => { failedQueue.forEach(({ resolve, reject }) => { if (error) { reject(error); } else if (token) { resolve(token); } }); failedQueue = []; }; const queueRequest = ( originalRequest: RetriableRequest, ): Promise => new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }).then((token) => { originalRequest.headers.Authorization = `Bearer ${token}`; return api(originalRequest); }); // --------------------------------------------------------------------------- // Session / auth helpers // --------------------------------------------------------------------------- const clearLocalSession = () => { Cookies.remove(COOKIE_KEYS.access_token); Cookies.remove(COOKIE_KEYS.refresh_token); Cookies.remove(COOKIE_KEYS.user); if (typeof window !== "undefined") { localStorage.removeItem("auth-token"); localStorage.removeItem("user-data"); } }; const redirectToSignIn = () => { if (typeof window !== "undefined") { window.location.href = SIGN_IN_PATH; } }; /** Logs the user out via the registered callback, falling back to a manual reset. */ const forceLogout = () => { const logout = getLogoutCallback(); if (logout) { logout(); return; } clearLocalSession(); redirectToSignIn(); }; // --------------------------------------------------------------------------- // Error reporting // --------------------------------------------------------------------------- /** * Builds the message shown in the error toast, appending the API-provided * `details` (e.g. field-level validation hints) to the main error message * when available. */ const buildErrorMessage = (data: unknown, fallback: string): string => { const payload = data as ApiErrorData | undefined; const message = payload?.error?.message || payload?.message || fallback; const details = payload?.error?.details; return details ? `${message}: ${details}` : message; }; const notifyError = (error: unknown) => { const data = (error as AxiosError).response?.data; toast.error(buildErrorMessage(data, (error as Error).message)); }; // --------------------------------------------------------------------------- // Token refresh // --------------------------------------------------------------------------- const requestNewAccessToken = async (refreshToken: string): Promise => { const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, { refresh_token: refreshToken, }); const { access_token, refresh_token } = response.data.data as TokenPair; if (!access_token || !refresh_token) { throw new Error("Invalid token received from refresh endpoint."); } Cookies.set(COOKIE_KEYS.access_token, access_token); Cookies.set(COOKIE_KEYS.refresh_token, refresh_token); return access_token; }; /** * Handles a 401 by refreshing the access token and replaying the request. * Concurrent requests are queued until the in-flight refresh settles. */ const handleUnauthorized = async (originalRequest: RetriableRequest) => { if (isRefreshing) { return queueRequest(originalRequest); } originalRequest._retry = true; isRefreshing = true; try { const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token); if (!refreshToken) { forceLogout(); return Promise.reject(new Error("Refresh token not found.")); } const accessToken = await requestNewAccessToken(refreshToken); processQueue(null, accessToken); originalRequest.headers.Authorization = `Bearer ${accessToken}`; return api(originalRequest); } catch (refreshError) { processQueue(refreshError, null); forceLogout(); notifyError(refreshError); return Promise.reject(refreshError); } finally { isRefreshing = false; } }; // --------------------------------------------------------------------------- // Request predicates // --------------------------------------------------------------------------- const matchesEndpoint = (request: RetriableRequest | undefined, endpoint: string) => request?.url?.includes(endpoint) ?? false; // Login errors must surface to the caller instead of triggering a refresh. const isLoginRequest = (request?: RetriableRequest) => matchesEndpoint(request, API_ENDPOINTS.USER.LOGIN); const isRefreshTokenRequest = (request?: RetriableRequest) => matchesEndpoint(request, API_ENDPOINTS.USER.REFRESH_TOKEN); // --------------------------------------------------------------------------- // Interceptors // --------------------------------------------------------------------------- export const responseInterceptor = (response: AxiosResponse) => response; export const responseErrorHandler = (error: AxiosError) => { const originalRequest = error.config as RetriableRequest | undefined; const isUnauthorized = error.response?.status === 401; // A 401 from the refresh endpoint itself means the session is unrecoverable. if (isUnauthorized && isRefreshTokenRequest(originalRequest)) { forceLogout(); return Promise.reject(error); } if ( isUnauthorized && originalRequest && !originalRequest._retry && !isLoginRequest(originalRequest) ) { return handleUnauthorized(originalRequest); } notifyError(error); return Promise.reject(error); };