367 lines
9.6 KiB
TypeScript
367 lines
9.6 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {},
|
|
isRetry = false
|
|
): Promise<T> {
|
|
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<T>(endpoint, options, true);
|
|
} else {
|
|
// Already refreshing, wait for it to complete
|
|
return new Promise((resolve, reject) => {
|
|
subscribeTokenRefresh(async () => {
|
|
try {
|
|
const result = await apiRequest<T>(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<T>(endpoint: string, includeAuth = true): Promise<T> {
|
|
return apiRequest<T>(endpoint, {
|
|
method: 'GET',
|
|
headers: createHeaders(includeAuth),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST request
|
|
*/
|
|
export async function post<T>(endpoint: string, data?: any, includeAuth = true): Promise<T> {
|
|
return apiRequest<T>(endpoint, {
|
|
method: 'POST',
|
|
headers: createHeaders(includeAuth),
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PATCH request
|
|
*/
|
|
export async function patch<T>(endpoint: string, data: any, includeAuth = true): Promise<T> {
|
|
return apiRequest<T>(endpoint, {
|
|
method: 'PATCH',
|
|
headers: createHeaders(includeAuth),
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* DELETE request
|
|
*/
|
|
export async function del<T>(endpoint: string, includeAuth = true): Promise<T> {
|
|
return apiRequest<T>(endpoint, {
|
|
method: 'DELETE',
|
|
headers: createHeaders(includeAuth),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Upload file (multipart/form-data)
|
|
*/
|
|
export async function upload<T>(
|
|
endpoint: string,
|
|
formData: FormData,
|
|
includeAuth = true
|
|
): Promise<T> {
|
|
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<void> {
|
|
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);
|
|
}
|