/** * API Client Base * * Core HTTP client with authentication and error handling. * * Authentication relies on httpOnly cookies set by the backend * (`accessToken` / `refreshToken`), so no token is ever stored in * localStorage or readable from JavaScript (XSS mitigation). * The non-httpOnly `xpeditis_session` flag cookie (which contains no * token) tells the frontend whether a session exists. */ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const SESSION_FLAG_COOKIE = 'xpeditis_session'; // Track if we're currently refreshing to avoid multiple simultaneous refresh requests let isRefreshing = false; let refreshSubscribers: Array<() => void> = []; /** * Whether an authenticated session exists (based on the readable flag cookie * set by the backend alongside the httpOnly token cookies). */ export function hasSession(): boolean { if (typeof document === 'undefined') return false; return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`)); } /** * Every domain scope a cookie could have been set on for the current host. * * The backend sets its cookies with `COOKIE_DOMAIN` (e.g. `.preprod.xpeditis.com`), * so deleting them from JS without the matching `domain` attribute silently * fails — the browser scopes the deletion to the exact host and the original * domain-scoped cookie survives. Trying every parent domain guarantees removal * whatever COOKIE_DOMAIN is set to. */ function cookieDomainScopes(): Array { const scopes: Array = [null]; // host-only cookie const host = window.location.hostname; // No domain cookies on IPs or on `localhost` if (host === 'localhost' || /^[\d.]+$/.test(host) || host.includes(':')) return scopes; const parts = host.split('.'); for (let i = 0; i < parts.length - 1; i++) { const domain = parts.slice(i).join('.'); if (domain.includes('.')) { scopes.push(domain); scopes.push(`.${domain}`); } } return scopes; } function deleteCookie(name: string): void { const expiry = 'expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; for (const domain of cookieDomainScopes()) { const domainPart = domain ? `; domain=${domain}` : ''; document.cookie = `${name}=; path=/${domainPart}; ${expiry}`; } } /** * Clear client-side auth state. * Token cookies are httpOnly and are cleared by the backend on logout; * this removes the user cache, any tokens left over from the legacy * localStorage-based auth, and the readable session flag. */ export function clearAuthTokens(): void { if (typeof window === 'undefined') return; localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); localStorage.removeItem('user'); sessionStorage.removeItem('access_token'); sessionStorage.removeItem('refresh_token'); sessionStorage.removeItem('user'); // Expire the legacy middleware cookie and the session flag (best effort — // the backend clears the authoritative httpOnly cookies) deleteCookie('accessToken'); deleteCookie(SESSION_FLAG_COOKIE); } /** * Pages that must never be redirected away from on an auth failure: they ARE * the destination. Redirecting from here caused an infinite reload loop when a * stale session cookie survived (`/login` → 401 → refresh fails → reload * `/login` → …). Matched after stripping the locale prefix. */ const AUTH_ENTRY_PATHS = [ '/login', '/admin/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/carrier', ]; // Kept in sync with i18n/routing.ts (imported statically here to avoid pulling // next-intl into the API client bundle). const LOCALES = ['fr', 'en']; function currentLocalePrefix(): string { const segment = window.location.pathname.split('/')[1]; return LOCALES.includes(segment) ? `/${segment}` : ''; } function isOnAuthEntryPage(): boolean { const path = window.location.pathname.slice(currentLocalePrefix().length) || '/'; return AUTH_ENTRY_PATHS.some(p => path === p || path.startsWith(`${p}/`)); } /** * Send the user back to login after an unrecoverable auth failure. * No-op when already on a login/registration page, so a stale session can never * turn into a reload loop. The locale prefix is preserved to avoid a needless * round-trip through the i18n middleware. */ export function redirectToLogin(): void { if (typeof window === 'undefined') return; if (isOnAuthEntryPage()) return; window.location.href = `${currentLocalePrefix()}/login`; } /** * Add subscriber to be notified when the session is refreshed */ function subscribeTokenRefresh(callback: () => void): void { refreshSubscribers.push(callback); } /** * Notify all subscribers that the session has been refreshed */ function onTokenRefreshed(): void { refreshSubscribers.forEach(callback => callback()); refreshSubscribers = []; } /** * Refresh the session — the backend reads the httpOnly refresh cookie and * sets new token cookies on success. */ async function refreshSession(): Promise { const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({}), }); if (!response.ok) { // Refresh token invalid or expired, clear everything clearAuthTokens(); redirectToLogin(); throw new Error('Failed to refresh session'); } } /** * Create headers (auth is carried by httpOnly cookies, not headers) */ export function createHeaders(_includeAuth = true): HeadersInit { return { 'Content-Type': 'application/json', }; } /** * Create headers for multipart form data */ export function createMultipartHeaders(_includeAuth = true): HeadersInit { return {}; } /** * API Error */ export class ApiError extends Error { /** Code machine renvoyé par l'API, ou `network_error` si elle est injoignable. */ public readonly code?: string; /** Référence d'incident à transmettre au support. */ public readonly reference?: string; constructor( message: string, public statusCode: number, public response?: any ) { super(message); this.name = 'ApiError'; this.code = response?.code; this.reference = response?.reference; } } /** * Make API request with automatic session refresh on 401 */ export async function apiRequest( endpoint: string, options: RequestInit = {}, isRetry = false ): Promise { const url = `${API_BASE_URL}${endpoint}`; let response: Response; try { response = await fetch(url, { ...options, credentials: 'include', headers: { ...options.headers, }, }); } catch { // Serveur injoignable, DNS, coupure réseau : `fetch` rejette avec un // « Failed to fetch » qui n'a rien à faire sous les yeux d'un utilisateur. throw new ApiError('', 0, { code: 'network_error' }); } // Handle 401 Unauthorized - token expired // Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors const isAuthEndpoint = endpoint.includes('/auth/login') || endpoint.includes('/auth/register') || endpoint.includes('/auth/refresh'); if (response.status === 401 && !isRetry && !isAuthEndpoint) { if (!hasSession()) { // No session, redirect to login clearAuthTokens(); redirectToLogin(); throw new ApiError('Session expired', 401); } // Try to refresh the session (cookies are updated server-side) try { if (!isRefreshing) { isRefreshing = true; await refreshSession(); isRefreshing = false; onTokenRefreshed(); return apiRequest(endpoint, options, true); } else { // Already refreshing, wait for it to complete return new Promise((resolve, reject) => { subscribeTokenRefresh(async () => { try { const result = await apiRequest(endpoint, options, true); resolve(result); } catch (error) { reject(error); } }); }); } } catch (refreshError) { isRefreshing = false; refreshSubscribers = []; throw refreshError; } } if (!response.ok) { const error = await response.json().catch(() => ({})); throw new ApiError( error.message || `API request failed: ${response.statusText}`, response.status, error ); } // Handle 204 No Content if (response.status === 204) { return undefined as T; } return response.json(); } /** * GET request */ export async function get(endpoint: string, includeAuth = true): Promise { return apiRequest(endpoint, { method: 'GET', headers: createHeaders(includeAuth), }); } /** * POST request */ export async function post(endpoint: string, data?: any, includeAuth = true): Promise { return apiRequest(endpoint, { method: 'POST', headers: createHeaders(includeAuth), body: data ? JSON.stringify(data) : undefined, }); } /** * PATCH request */ export async function patch(endpoint: string, data: any, includeAuth = true): Promise { return apiRequest(endpoint, { method: 'PATCH', headers: createHeaders(includeAuth), body: JSON.stringify(data), }); } /** * DELETE request */ export async function del(endpoint: string, includeAuth = true): Promise { return apiRequest(endpoint, { method: 'DELETE', headers: createHeaders(includeAuth), }); } /** * Upload file (multipart/form-data) */ export async function upload( endpoint: string, formData: FormData, includeAuth = true ): Promise { const url = `${API_BASE_URL}${endpoint}`; const response = await fetch(url, { method: 'POST', credentials: 'include', headers: createMultipartHeaders(includeAuth), body: formData, }); // Handle 401 Unauthorized for file uploads if (response.status === 401 && hasSession()) { try { await refreshSession(); // Retry upload with refreshed cookies const retryResponse = await fetch(url, { method: 'POST', credentials: 'include', headers: createMultipartHeaders(includeAuth), body: formData, }); if (!retryResponse.ok) { const error = await retryResponse.json().catch(() => ({})); throw new ApiError( error.message || `Upload failed: ${retryResponse.statusText}`, retryResponse.status, error ); } return retryResponse.json(); } catch (refreshError) { clearAuthTokens(); redirectToLogin(); throw refreshError; } } if (!response.ok) { const error = await response.json().catch(() => ({})); throw new ApiError( error.message || `Upload failed: ${response.statusText}`, response.status, error ); } return response.json(); } /** * Download file */ export async function download( endpoint: string, filename: string, includeAuth = true ): Promise { const url = `${API_BASE_URL}${endpoint}`; const response = await fetch(url, { method: 'GET', credentials: 'include', headers: createHeaders(includeAuth), }); // Handle 401 Unauthorized for downloads if (response.status === 401 && hasSession()) { try { await refreshSession(); // Retry download with refreshed cookies const retryResponse = await fetch(url, { method: 'GET', credentials: 'include', headers: createHeaders(includeAuth), }); if (!retryResponse.ok) { throw new ApiError(`Download failed: ${retryResponse.statusText}`, retryResponse.status); } const blob = await retryResponse.blob(); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); return; } catch (refreshError) { clearAuthTokens(); redirectToLogin(); throw refreshError; } } if (!response.ok) { throw new ApiError(`Download failed: ${response.statusText}`, response.status); } const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); }