Initial commit

This commit is contained in:
2026-06-04 06:24:56 +02:00
commit 282f4864c4
111 changed files with 8083 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
import axios, {
AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from "axios";
import {
clearAuth,
getAccessToken,
getRefreshToken,
setTokens,
} from "@/store/authStore";
import type { TokenResponse } from "@/types/auth.types";
// All calls go through "/api"; nginx (prod) and the Vite dev proxy forward it to the API.
export const api: AxiosInstance = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
});
// A bare client for the refresh call so it never triggers the response interceptor (no loops).
const refreshClient = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// --- Silent refresh handling -------------------------------------------------
// While a refresh is in flight, queue concurrent 401s and replay them once done.
let isRefreshing = false;
let pendingQueue: Array<(token: string | null) => void> = [];
function flushQueue(token: string | null): void {
pendingQueue.forEach((resolve) => resolve(token));
pendingQueue = [];
}
function redirectToLogin(): void {
clearAuth();
const path = window.location.pathname;
if (path !== "/login" && path !== "/register") {
window.location.assign("/login");
}
}
async function performRefresh(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const { data } = await refreshClient.post<TokenResponse>("/auth/refresh", {
refreshToken,
});
setTokens(data);
return data.accessToken;
} catch {
return null;
}
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as
| (InternalAxiosRequestConfig & { _retry?: boolean })
| undefined;
const status = error.response?.status;
const isAuthEndpoint =
original?.url?.includes("/auth/login") ||
original?.url?.includes("/auth/register") ||
original?.url?.includes("/auth/refresh");
if (status !== 401 || !original || original._retry || isAuthEndpoint) {
return Promise.reject(error);
}
original._retry = true;
if (isRefreshing) {
// Wait for the in-flight refresh, then retry (or fail) with its result.
return new Promise((resolve, reject) => {
pendingQueue.push((token) => {
if (!token) {
reject(error);
return;
}
original.headers.Authorization = `Bearer ${token}`;
resolve(api(original));
});
});
}
isRefreshing = true;
const newToken = await performRefresh();
isRefreshing = false;
flushQueue(newToken);
if (!newToken) {
redirectToLogin();
return Promise.reject(error);
}
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
},
);
/** Extracts a user-facing message from an API error response. */
export function extractApiError(error: unknown, fallback = "Something went wrong."): string {
if (axios.isAxiosError(error)) {
const data = error.response?.data as { error?: string; title?: string } | undefined;
return data?.error ?? data?.title ?? error.message ?? fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}