import axios from "axios"; import toast from "react-hot-toast"; import { API_BASE_URL } from "@/constants/apiConfig"; import { captureApiError } from "@/lib/posthog"; import { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, clearSessionCookies, getCookie, setCookie, } from "./cookies"; import type { AuthTokens } from "./types"; import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal"; declare module "axios" { export interface AxiosRequestConfig { /** * When true, the response interceptor does NOT raise the global error * toast for this request's failure. For calls the caller handles itself — * e.g. a probe that is expected to 404 before falling back (GL clearance * detail tries /contracts/:id then /bookings/:id). The rejection still * propagates. */ suppressErrorModal?: boolean; } } type RetriableRequest = { _retry?: boolean; headers?: Record; url?: string; suppressErrorModal?: boolean; }; const api = axios.create({ baseURL: `${API_BASE_URL}/api`, withCredentials: true, }); let refreshPromise: Promise | null = null; const applyTokens = ({ token, refreshToken }: AuthTokens) => { setCookie(AUTH_TOKEN_COOKIE, token); setCookie(REFRESH_TOKEN_COOKIE, refreshToken); }; /** * Single-flight token refresh: concurrent callers (the 401 interceptor and * the proactive scheduler) share one in-flight request so the refresh token * is only rotated once. Throws if no refresh token is stored or the server * rejects it — callers decide how to end the session. */ const refreshSessionTokens = async (): Promise => { const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); if (!refreshToken) { throw new Error("missing refresh token"); } refreshPromise ??= api .post("/auth/refresh-token", { refreshToken }) .then((response) => response.data) .finally(() => { refreshPromise = null; }); const tokens = await refreshPromise; applyTokens(tokens); return tokens; }; api.interceptors.request.use((config) => { const token = getCookie(AUTH_TOKEN_COOKIE); if (token) { config.headers.Authorization = `Bearer ${token}`; } // Tells the backend which app is asking, so /auth/login can reject // cross-audience credentials (EDRFREIGHT-415). config.headers["X-Client-App"] = "backoffice"; return config; }); api.interceptors.response.use( (response) => { if ( response.data && typeof response.data === "object" && "success" in response.data && "data" in response.data ) { response.data = response.data.data; } return response; }, async (error) => { const originalRequest = error.config as RetriableRequest | undefined; // Report the failure to PostHog, including on suppressErrorModal paths — // those opt out of the user-facing toast, not of reporting. // 401s are skipped: an expired session is refreshed below, not a defect. if (!error.response || error.response.status !== 401) { captureApiError(error); } if ( error.response?.status !== 401 || !originalRequest || originalRequest._retry || originalRequest.url?.includes("/auth/login") || originalRequest.url?.includes("/auth/mfa-verify") || originalRequest.url?.includes("/auth/refresh-token") ) { // Surface the server's actual error message in a global toast — never // the error modal (401s are handled by the session-refresh flow, so skip // them). A request may opt out via `suppressErrorModal` when it handles // the failure itself. if (error.response && error.response.status !== 401) { const payload = extractApiErrorPayload(error); // Normalize the error's own `message` to the SERVER's actual message so // every downstream `toast.error(err.message)` handler shows the real // cause instead of "Request failed with status code NNN". Applies even // on suppressErrorModal paths — only the toast is opted out. if (payload?.messages.length) { const message = payload.messages.join("\n"); (error as { message?: string }).message = message; // Keyed by message so a retried request replaces its toast instead // of stacking duplicates. if (!originalRequest?.suppressErrorModal) { toast.error(message, { id: message }); } } } return Promise.reject(error); } if (!getCookie(REFRESH_TOKEN_COOKIE)) { clearSessionCookies(); return Promise.reject(error); } originalRequest._retry = true; try { const tokens = await refreshSessionTokens(); originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${tokens.token}`, }; return api(originalRequest); } catch (refreshError) { clearSessionCookies(); window.location.replace("/auth"); return Promise.reject(refreshError); } }, ); export { api, applyTokens, refreshSessionTokens };