/** * 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}=`)); } /** * Clear client-side auth state. * Token cookies are httpOnly and are cleared by the backend on logout; * this removes the user cache and any tokens left over from the legacy * localStorage-based auth. */ 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) document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; document.cookie = `${SESSION_FLAG_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`; } /** * 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(); // Redirect to login if (typeof window !== 'undefined') { window.location.href = '/login'; } 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 { constructor( message: string, public statusCode: number, public response?: any ) { super(message); this.name = 'ApiError'; } } /** * 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}`; const response = await fetch(url, { ...options, credentials: 'include', headers: { ...options.headers, }, }); // 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(); if (typeof window !== 'undefined') { window.location.href = '/login'; } 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(); if (typeof window !== 'undefined') { window.location.href = '/login'; } 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(); if (typeof window !== 'undefined') { window.location.href = '/login'; } 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); }