import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import { URL_CONSTANTS } from "@/constants/URLS"; import { API_BASE_URL } from "@/constants/apiConfig"; import { emitApiError, extractApiErrorPayload, } from "@/components/errors/ApiErrorModal"; import { captureApiError } from "@/lib/posthog"; const client = axios.create({ baseURL: API_BASE_URL, }); function getCookie(name: string): string | undefined { return document.cookie .split("; ") .find((row) => row.startsWith(`${name}=`)) ?.split("=")[1]; } function setCookie(name: string, value: string, days: number) { const expires = new Date(); expires.setDate(expires.getDate() + days); document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`; } function clearAuthCookies() { [ "auth-token", "refresh-token", "auth-user", "current-position-id", "selected-position-id", ].forEach((name) => { document.cookie = `${name}=; Max-Age=0; path=/`; }); } // Attach auth token to every request client.interceptors.request.use((config) => { const token = getCookie("auth-token"); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // Token refresh state let refreshPromise: Promise | null = null; /** * 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. */ async function refreshSessionTokens(): Promise { refreshPromise ??= (async () => { const refreshToken = getCookie("refresh-token"); if (!refreshToken) { throw new Error("missing refresh token"); } type TokenPair = { token: string; refreshToken: string }; const { data } = await client.post & { data?: TokenPair }>( URL_CONSTANTS.AUTH.REFRESH_TOKEN, { refreshToken }, ); // The API returns the pair flat ({ success, token, refreshToken }); accept // a { data: { ... } }-wrapped shape too so a transform change can't // silently break refresh again. const payload = data.data ?? data; if (!payload.token || !payload.refreshToken) { throw new Error("malformed refresh-token response"); } setCookie("auth-token", payload.token, 7); setCookie("refresh-token", payload.refreshToken, 7); return payload.token; })().finally(() => { refreshPromise = null; }); return refreshPromise; } // Handle auth errors globally with token refresh client.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean; }; // Report the failure to PostHog. Hooked here rather than inside // `emitApiError`, which stays silent on suppressed paths (warehouse / // mile / onboarding) — those failures still need reporting. // 401s are skipped: an expired session is refreshed below, not a defect. if (!error.response || error.response.status !== 401) { captureApiError(error); } // Don't intercept if: // - no response (network error) // - status is not 401 // - already retried // - it's the refresh endpoint itself // - it's a credential endpoint (401 there = wrong credentials, not an // expired session — refreshing would mask the real error) if ( !error.response || error.response.status !== 401 || originalRequest._retry || originalRequest.url === URL_CONSTANTS.AUTH.REFRESH_TOKEN || originalRequest.url === URL_CONSTANTS.AUTH.LOGIN || originalRequest.url === URL_CONSTANTS.USERS.SIGN_UP ) { // Surface the server's actual error message in the global error modal // (401s are handled by the session flow below/redirects, so skip them). if (error.response && error.response.status !== 401) { const payload = extractApiErrorPayload(error); if (payload) emitApiError(payload); } return Promise.reject(error); } // Nothing to refresh with (e.g. not logged in yet) — surface the // original error instead of a confusing refresh failure. if (!getCookie("refresh-token")) { if (getCookie("auth-token")) { // Half-broken cookie state; reset it. clearAuthCookies(); } return Promise.reject(error); } originalRequest._retry = true; try { const token = await refreshSessionTokens(); originalRequest.headers.Authorization = `Bearer ${token}`; return client(originalRequest); } catch (refreshError) { clearAuthCookies(); return Promise.reject(refreshError); } }, ); export { client, clearAuthCookies, getCookie, refreshSessionTokens }; export type { UseQueryOptions, QueryObserverOptions };