202 lines
5.6 KiB
TypeScript
202 lines
5.6 KiB
TypeScript
import { API_BASE_URL } from './config';
|
|
|
|
// Token management
|
|
let accessToken: string | null = null;
|
|
|
|
export function setAccessToken(token: string | null): void {
|
|
accessToken = token;
|
|
if (token) {
|
|
localStorage.setItem('access_token', token);
|
|
} else {
|
|
localStorage.removeItem('access_token');
|
|
}
|
|
}
|
|
|
|
export function getAccessToken(): string | null {
|
|
if (accessToken) return accessToken;
|
|
if (typeof window !== 'undefined') {
|
|
accessToken = localStorage.getItem('access_token');
|
|
}
|
|
return accessToken;
|
|
}
|
|
|
|
export function clearTokens(): void {
|
|
accessToken = null;
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.removeItem('access_token');
|
|
localStorage.removeItem('refresh_token');
|
|
}
|
|
}
|
|
|
|
// Parse JWT payload without library dependency
|
|
export function parseJwt(token: string): Record<string, unknown> | null {
|
|
try {
|
|
const base64Url = token.split('.')[1];
|
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
const jsonPayload = decodeURIComponent(
|
|
atob(base64)
|
|
.split('')
|
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
|
.join('')
|
|
);
|
|
return JSON.parse(jsonPayload);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface FetchOptions extends RequestInit {
|
|
skipAuth?: boolean;
|
|
}
|
|
|
|
export function invalidateApiCache() {
|
|
// No-op: Caching disabled for real-time admin management
|
|
}
|
|
|
|
// Core API fetch wrapper with auth header injection
|
|
export async function apiFetch<T>(
|
|
endpoint: string,
|
|
options: FetchOptions = {}
|
|
): Promise<T> {
|
|
const { skipAuth = false, headers: customHeaders, ...rest } = options;
|
|
const method = (rest.method || 'GET').toUpperCase();
|
|
return runApiFetch<T>(endpoint, { skipAuth, headers: customHeaders, ...rest }, method);
|
|
}
|
|
|
|
async function runApiFetch<T>(
|
|
endpoint: string,
|
|
options: FetchOptions,
|
|
method: string
|
|
): Promise<T> {
|
|
const { skipAuth = false, headers: customHeaders, ...rest } = options;
|
|
|
|
const headers: Record<string, string> = {
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
'Pragma': 'no-cache',
|
|
...(customHeaders as Record<string, string>),
|
|
};
|
|
|
|
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type']) {
|
|
if (!(rest.body instanceof FormData)) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
}
|
|
|
|
if (!skipAuth) {
|
|
const token = getAccessToken();
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
} else {
|
|
delete headers['Authorization'];
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
cache: 'no-store',
|
|
...rest,
|
|
headers,
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (response.status === 401 && !skipAuth) {
|
|
// Try token refresh
|
|
const refreshed = await refreshAccessToken();
|
|
if (refreshed) {
|
|
headers['Authorization'] = `Bearer ${getAccessToken()}`;
|
|
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
...rest,
|
|
headers,
|
|
credentials: 'include',
|
|
});
|
|
if (!retryResponse.ok) {
|
|
const err = await retryResponse.json().catch(() => ({ detail: 'Request failed' }));
|
|
throw new ApiError(retryResponse.status, formatErrorMessage(err.detail));
|
|
}
|
|
return retryResponse.json();
|
|
}
|
|
// Refresh failed — redirect to login
|
|
clearTokens();
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = '/login';
|
|
}
|
|
throw new ApiError(401, 'Session expired');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const err = await response.json().catch(() => ({ detail: 'Request failed' }));
|
|
throw new ApiError(response.status, formatErrorMessage(err.detail));
|
|
}
|
|
|
|
return response.json();
|
|
} catch (error: any) {
|
|
if (error instanceof ApiError) {
|
|
throw error;
|
|
}
|
|
console.error('apiFetch network/CORS error:', error);
|
|
throw new ApiError(
|
|
503,
|
|
'Network issue — please refresh page or check connection'
|
|
);
|
|
}
|
|
}
|
|
|
|
async function refreshAccessToken(): Promise<boolean> {
|
|
try {
|
|
const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refresh_token') : null;
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json'
|
|
};
|
|
if (refreshToken) {
|
|
headers['Authorization'] = `Bearer ${refreshToken}`;
|
|
headers['X-Refresh-Token'] = refreshToken;
|
|
}
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
|
|
method: 'POST',
|
|
headers,
|
|
credentials: 'include',
|
|
});
|
|
if (!response.ok) return false;
|
|
const data = await response.json();
|
|
if (data.access_token) {
|
|
setAccessToken(data.access_token);
|
|
}
|
|
if (data.refresh_token) {
|
|
localStorage.setItem('refresh_token', data.refresh_token);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function formatErrorMessage(detail: any): string {
|
|
if (!detail) return 'Request failed';
|
|
if (typeof detail === 'string') return detail;
|
|
if (Array.isArray(detail)) {
|
|
return detail
|
|
.map((item: any) => {
|
|
if (typeof item === 'string') return item;
|
|
if (item && typeof item === 'object') {
|
|
const loc = Array.isArray(item.loc) ? item.loc.slice(1).join('.') : '';
|
|
const msg = item.msg || item.message || JSON.stringify(item);
|
|
return loc ? `${loc}: ${msg}` : msg;
|
|
}
|
|
return String(item);
|
|
})
|
|
.join(' | ');
|
|
}
|
|
if (typeof detail === 'object') {
|
|
return detail.message || detail.msg || JSON.stringify(detail);
|
|
}
|
|
return String(detail);
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|