/** * API Client Base * * Core HTTP client with authentication and error handling */ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; // Track if we're currently refreshing to avoid multiple simultaneous refresh requests let isRefreshing = false; let refreshSubscribers: Array<(token: string) => void> = []; /** * Get authentication token from localStorage */ export function getAuthToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem('access_token'); } /** * Get refresh token from localStorage */ export function getRefreshToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem('refresh_token'); } /** * Set authentication tokens */ export function setAuthTokens(accessToken: string, refreshToken: string): void { if (typeof window === 'undefined') return; localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken); } /** * Clear authentication tokens */ export function clearAuthTokens(): void { if (typeof window === 'undefined') return; localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); localStorage.removeItem('user'); } /** * Add subscriber to be notified when token is refreshed */ function subscribeTokenRefresh(callback: (token: string) => void): void { refreshSubscribers.push(callback); } /** * Notify all subscribers that token has been refreshed */ function onTokenRefreshed(token: string): void { refreshSubscribers.forEach(callback => callback(token)); refreshSubscribers = []; } /** * Refresh access token using refresh token */ async function refreshAccessToken(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available'); } const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refreshToken }), }); 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 token'); } const data = await response.json(); const newAccessToken = data.accessToken; // Update access token in localStorage (keep same refresh token) if (typeof window !== 'undefined') { localStorage.setItem('access_token', newAccessToken); } return newAccessToken; } /** * Create headers with authentication */ export function createHeaders(includeAuth = true): HeadersInit { const headers: HeadersInit = { 'Content-Type': 'application/json', }; if (includeAuth) { const token = getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } } return headers; } /** * Create headers for multipart form data */ export function createMultipartHeaders(includeAuth = true): HeadersInit { const headers: HeadersInit = {}; if (includeAuth) { const token = getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } } return headers; } /** * 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 token 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, headers: { ...options.headers, }, }); // Handle 401 Unauthorized - token expired if (response.status === 401 && !isRetry && !endpoint.includes('/auth/refresh')) { // Check if we have a refresh token const refreshToken = getRefreshToken(); if (!refreshToken) { // No refresh token, redirect to login clearAuthTokens(); if (typeof window !== 'undefined') { window.location.href = '/login'; } throw new ApiError('Session expired', 401); } // Try to refresh the token try { if (!isRefreshing) { isRefreshing = true; const newAccessToken = await refreshAccessToken(); isRefreshing = false; onTokenRefreshed(newAccessToken); // Retry the original request with new token const newHeaders = { ...options.headers }; if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) { (newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`; } return apiRequest( endpoint, { ...options, headers: newHeaders, }, true ); } else { // Already refreshing, wait for the new token return new Promise((resolve, reject) => { subscribeTokenRefresh(async (newAccessToken: string) => { const newHeaders = { ...options.headers }; if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) { (newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`; } try { const result = await apiRequest( endpoint, { ...options, headers: newHeaders, }, 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', headers: createMultipartHeaders(includeAuth), body: formData, }); // Handle 401 Unauthorized for file uploads if (response.status === 401) { const refreshToken = getRefreshToken(); if (refreshToken) { try { const newAccessToken = await refreshAccessToken(); // Retry upload with new token const retryResponse = await fetch(url, { method: 'POST', headers: { ...createMultipartHeaders(includeAuth), Authorization: `Bearer ${newAccessToken}`, }, 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', headers: createHeaders(includeAuth), }); // Handle 401 Unauthorized for downloads if (response.status === 401) { const refreshToken = getRefreshToken(); if (refreshToken) { try { const newAccessToken = await refreshAccessToken(); // Retry download with new token const retryResponse = await fetch(url, { method: 'GET', headers: { ...createHeaders(includeAuth), Authorization: `Bearer ${newAccessToken}`, }, }); 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); }