import axios from 'axios';
const api = axios.create({
baseURL: '',
withCredentials: true,
timeout: 120_000,
headers: {
'Content-Type': 'application/json',
},
});
// ─── CSRF token handling ────────────────────────────────────────────────────
// The backend enforces X-CSRF-Token on all authenticated state-changing
// requests (POST/PUT/DELETE/PATCH). Fetch the token lazily from
// /api/csrf-token, cache it, and attach it via the request interceptor.
let csrfToken: string | null = null;
let csrfFetch: Promise<string | null> | null = null;
async function fetchCsrfToken(): Promise<string | null> {
if (!csrfFetch) {
csrfFetch = axios
.get<{ token: string }>('/api/csrf-token', { withCredentials: true })
.then((res) => {
csrfToken = res.data.token;
return csrfToken;
})
.catch(() => null)
.finally(() => {
csrfFetch = null;
});
}
return csrfFetch;
}
const MUTATING_METHODS = ['post', 'put', 'delete', 'patch'];
// Request interceptor — attach CSRF token to state-changing requests
api.interceptors.request.use(async (config) => {
const method = (config.method || 'get').toLowerCase();
if (MUTATING_METHODS.includes(method)) {
if (!csrfToken) {
await fetchCsrfToken();
}
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken;
}
}
return config;
});
// Response interceptor - handle auth errors
api.interceptors.response.use(
(response) => response,
async (error) => {
// CSRF token missing/invalid (e.g. session rotated) — refresh token and retry once
if (
error.response?.status === 403 &&
typeof error.response?.data?.error === 'string' &&
error.response.data.error.toLowerCase().includes('csrf') &&
!error.config?._csrfRetried
) {
csrfToken = null;
const token = await fetchCsrfToken();
if (token) {
error.config._csrfRetried = true;
error.config.headers['X-CSRF-Token'] = token;
return api.request(error.config);
}
}
if (error.response?.status === 401) {
const url = error.config?.url || '';
// Don't intercept auth endpoint errors — let the login page handle them.
if (!url.includes('/auth/') && !url.includes('/coaching/')) {
// Prevent multiple interceptors from firing simultaneously.
if (window.location.pathname !== '/auth/login' && window.location.pathname !== '/auth/signup') {
// Await logout to actually clear the session cookie before redirecting.
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch {
// Ignore — session might already be invalid.
}
// Use replace instead of href to avoid the user hitting "back" into a broken state.
window.location.replace('/auth/login');
}
}
}
return Promise.reject(error);
}
);
export default api;
export const axiosInstance = api;