// src/lib/api/client.ts import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import type { ApiError } from '@/types/api'; import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents'; interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig { _retry?: boolean; } export const apiClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, withCredentials: true, // ✅ REQUIRED FOR COOKIES headers: { 'Content-Type': 'application/json', }, timeout: 10000, }); /** Invitation preview/accept must work with no cookies (public API, no JWT). */ function isPublicInvitationRequest(url: string | undefined): boolean { if (!url) return false; return ( url.includes('/staff/invitations/preview') || url.includes('/staff/invitations/accept') || url.includes('/organizations/invitations/preview') || url.includes('/organizations/invitations/accept') ); } /** Session bootstrap / auth endpoints where 401 means "not logged in", not "retry refresh". */ function shouldSkipRefreshRetry(url: string | undefined): boolean { if (!url) return false; return ( url.includes('/auth/refresh') || url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/logout') || url.includes('/auth/forgot-password') || url.includes('/auth/profile/password') ); } // ❌ REMOVE request interceptor completely (no Authorization header) // ✅ Response interceptor apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as CustomAxiosRequestConfig; if ( error.response?.status === 401 && !originalRequest._retry && !isPublicInvitationRequest(originalRequest.url) && !shouldSkipRefreshRetry(originalRequest.url) ) { originalRequest._retry = true; try { // Refresh access token, then restore selected organization context on the new JWT. await axios.post( `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {}, { withCredentials: true }, ); const orgId = typeof window !== 'undefined' ? localStorage.getItem('currentOrganizationId') : null; if (orgId) { try { await axios.post( `${process.env.NEXT_PUBLIC_API_URL}/auth/select-organization`, { organizationId: orgId }, { withCredentials: true }, ); } catch { /* original retry may still succeed if refresh preserved organizationId */ } } notifyAccessTokenRefreshed(); return apiClient(originalRequest); } catch (refreshError) { if (typeof window !== 'undefined') { return Promise.reject(error); // ✅ just fail silently } return Promise.reject(refreshError); } } const responseData = error.response?.data as | { success?: false; error?: { code?: string; statusCode?: number; details?: ApiError['details'] } } | { message?: string | string[] } | undefined; if (responseData && 'error' in responseData && responseData.error?.code) { const apiError: ApiError = { statusCode: responseData.error.statusCode ?? error.response?.status ?? 500, code: responseData.error.code, details: responseData.error.details, }; return Promise.reject(apiError); } const apiError: ApiError = { statusCode: error.response?.status || 500, code: error.response?.status === 401 ? 'AUTH_UNAUTHORIZED' : error.response?.status === 403 ? 'PERMISSION_DENIED' : error.response?.status === 404 ? 'NOT_FOUND' : error.response?.status === 409 ? 'CONFLICT' : (error.response?.status ?? 500) >= 500 ? 'INTERNAL_ERROR' : 'BAD_REQUEST', }; return Promise.reject(apiError); } );