195 lines
5.2 KiB
TypeScript
195 lines
5.2 KiB
TypeScript
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
|
|
|
const http: AxiosInstance = axios.create({
|
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
// Do not force a Content-Type globally.
|
|
// Axios will set the correct header based on the request body (JSON vs multipart FormData).
|
|
headers: {},
|
|
});
|
|
|
|
let isRefreshing = false;
|
|
let failedQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (error: Error) => void;
|
|
}> = [];
|
|
const TOKEN_REFRESH_BUFFER_SECONDS = 120;
|
|
|
|
const processQueue = (error: Error | null, token: string | null = null) => {
|
|
failedQueue.forEach((prom) => {
|
|
if (error) {
|
|
prom.reject(error);
|
|
} else if (token) {
|
|
prom.resolve(token);
|
|
}
|
|
});
|
|
failedQueue = [];
|
|
};
|
|
|
|
const clearAuthAndRedirect = () => {
|
|
localStorage.removeItem("access_token");
|
|
localStorage.removeItem("refresh_token");
|
|
localStorage.removeItem("member_id");
|
|
localStorage.removeItem("role");
|
|
window.location.href = "/login";
|
|
};
|
|
|
|
const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
|
|
try {
|
|
const payloadPart = token.split(".")[1];
|
|
if (!payloadPart) return null;
|
|
const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
const json = atob(padded);
|
|
return JSON.parse(json) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const isAccessTokenExpiringSoon = (token: string) => {
|
|
const payload = decodeJwtPayload(token);
|
|
const exp = Number(payload?.exp);
|
|
if (!Number.isFinite(exp)) return true;
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
return exp - nowSeconds <= TOKEN_REFRESH_BUFFER_SECONDS;
|
|
};
|
|
|
|
const isAuthEndpointRequest = (url?: string) => {
|
|
if (!url) return false;
|
|
return (
|
|
url.includes("/team/login") ||
|
|
url.includes("/team/google-login") ||
|
|
url.includes("/team/refresh")
|
|
);
|
|
};
|
|
|
|
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
|
|
|
|
const safeOrigin = (url?: string): string | null => {
|
|
if (!url) return null;
|
|
try {
|
|
return new URL(url).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const API_BASE_ORIGIN = safeOrigin(import.meta.env.VITE_API_BASE_URL);
|
|
|
|
const shouldAttachApiAuth = (url?: string): boolean => {
|
|
if (!url) return true;
|
|
if (!ABSOLUTE_URL_REGEX.test(url)) return true;
|
|
const requestOrigin = safeOrigin(url);
|
|
if (!requestOrigin || !API_BASE_ORIGIN) return false;
|
|
return requestOrigin === API_BASE_ORIGIN;
|
|
};
|
|
|
|
const refreshAccessToken = async (): Promise<string> => {
|
|
const refreshToken = localStorage.getItem("refresh_token");
|
|
|
|
if (!refreshToken) {
|
|
throw new Error("No refresh token available");
|
|
}
|
|
|
|
const response = await axios.post(
|
|
`${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
|
|
{
|
|
refresh_token: refreshToken,
|
|
}
|
|
);
|
|
|
|
const newAccessToken = response.data?.data?.access_token;
|
|
const newRefreshToken = response.data?.data?.refresh_token;
|
|
|
|
if (newAccessToken) {
|
|
localStorage.setItem("access_token", newAccessToken);
|
|
}
|
|
if (newRefreshToken) {
|
|
localStorage.setItem("refresh_token", newRefreshToken);
|
|
}
|
|
|
|
return newAccessToken;
|
|
};
|
|
|
|
const getValidAccessToken = async (forceRefresh = false): Promise<string> => {
|
|
const currentToken = localStorage.getItem("access_token");
|
|
if (!forceRefresh && currentToken && !isAccessTokenExpiringSoon(currentToken)) {
|
|
return currentToken;
|
|
}
|
|
|
|
if (isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
failedQueue.push({ resolve, reject });
|
|
});
|
|
}
|
|
|
|
isRefreshing = true;
|
|
try {
|
|
const newToken = await refreshAccessToken();
|
|
processQueue(null, newToken);
|
|
return newToken;
|
|
} catch (refreshError) {
|
|
processQueue(refreshError as Error, null);
|
|
clearAuthAndRedirect();
|
|
throw refreshError;
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
};
|
|
|
|
// Attach access token to every request
|
|
http.interceptors.request.use(async (config) => {
|
|
if (!shouldAttachApiAuth(config.url)) {
|
|
return config;
|
|
}
|
|
|
|
if (isAuthEndpointRequest(config.url)) {
|
|
return config;
|
|
}
|
|
|
|
let token = localStorage.getItem("access_token");
|
|
if (token && isAccessTokenExpiringSoon(token)) {
|
|
token = await getValidAccessToken();
|
|
}
|
|
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Handle 401 globally with token refresh
|
|
http.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
|
|
|
if (
|
|
error.response?.status === 401 &&
|
|
!originalRequest._retry &&
|
|
shouldAttachApiAuth(originalRequest.url) &&
|
|
!isAuthEndpointRequest(originalRequest.url)
|
|
) {
|
|
originalRequest._retry = true;
|
|
|
|
try {
|
|
const newToken = await getValidAccessToken(true);
|
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
return http(originalRequest);
|
|
} catch (refreshError) {
|
|
return Promise.reject(refreshError);
|
|
}
|
|
}
|
|
|
|
// Backend is down (network error, timeout, connection refused)
|
|
if (!error.response && shouldAttachApiAuth(originalRequest.url)) {
|
|
clearAuthAndRedirect();
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
export default http;
|