135 lines
3.6 KiB
TypeScript
135 lines
3.6 KiB
TypeScript
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. */
|
|
const GENERIC_API_TITLES = new Set([
|
|
"An unexpected error occurred.",
|
|
"Internal Server Error",
|
|
]);
|
|
|
|
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;
|
|
if (data?.error) return data.error;
|
|
if (data?.title && !GENERIC_API_TITLES.has(data.title)) return data.title;
|
|
return fallback;
|
|
}
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return fallback;
|
|
}
|