Files
template-vitrine/frontend/src/api/client.ts
T
2026-09-14 20:50:19 +02:00

87 lines
2.5 KiB
TypeScript

// Central fetch wrapper: the access token is kept in memory only (never
// localStorage/sessionStorage, per the auth design), and a 401 triggers a
// single silent refresh-and-retry via the httpOnly refresh cookie.
let accessToken: string | null = null;
export function setAccessToken(token: string | null): void {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
const NO_RETRY_PATHS = ["/api/auth/admin/refresh", "/api/auth/admin/login"];
async function rawFetch(path: string, init: RequestInit): Promise<Response> {
const headers = new Headers(init.headers);
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return fetch(path, { ...init, headers, credentials: "include" });
}
let refreshInFlight: Promise<boolean> | null = null;
async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const res = await fetch("/api/auth/admin/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) return false;
const data = (await res.json()) as { access_token: string };
setAccessToken(data.access_token);
return true;
} catch {
return false;
} finally {
refreshInFlight = null;
}
})();
}
return refreshInFlight;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let res = await rawFetch(path, init);
if (res.status === 401 && !NO_RETRY_PATHS.includes(path)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
res = await rawFetch(path, init);
}
}
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
try {
const body = (await res.json()) as { error?: string };
if (body?.error) message = body.error;
} catch {
// response had no JSON body
}
throw new ApiError(message, res.status);
}
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
}
export function apiUpload<T>(path: string, formData: FormData): Promise<T> {
return apiFetch<T>(path, { method: "POST", body: formData });
}