Files
dyolink/frontend/src/lib/api/client.ts

125 lines
4.0 KiB
TypeScript

// 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);
}
);