Files
tm_panel/src/middleware/response-interceptor.ts
T
AmirReza Jamali da1e2f89e7 feat(documents): implement document upload functionality for companies
- Added DocumentItem component to display uploaded documents in CompanyDetailsPage.
- Introduced useUploadCompanyDocuments hook for handling document uploads.
- Updated CreateCompany form to include document upload capabilities and validation for file types and sizes.
- Enhanced API services to support batch document uploads and retrieval of document information.
- Improved response handling in the file service for better error management and user feedback.
2026-06-06 17:18:02 +03:30

209 lines
6.3 KiB
TypeScript

import {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import Cookies from "js-cookie";
import { toast } from "react-toastify";
import { getLogoutCallback } from "../contexts/User.ctx";
import { API_ENDPOINTS, api } from "../lib/api";
import { COOKIE_KEYS } from "../lib/shared/cookies";
const SIGN_IN_PATH = "/auth/sign-in";
type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean };
interface TokenPair {
access_token: string;
refresh_token: string;
}
interface ApiErrorData {
message?: string;
error?: {
code?: string;
message?: string;
details?: string;
};
}
interface QueuedRequest {
resolve: (token: string) => void;
reject: (reason?: unknown) => void;
}
// ---------------------------------------------------------------------------
// Refresh-token queue: requests that arrive mid-refresh wait here and replay
// once a fresh access token is available (or fail together if refresh fails).
// ---------------------------------------------------------------------------
let isRefreshing = false;
let failedQueue: QueuedRequest[] = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else if (token) {
resolve(token);
}
});
failedQueue = [];
};
const queueRequest = (
originalRequest: RetriableRequest,
): Promise<AxiosResponse> =>
new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
// ---------------------------------------------------------------------------
// Session / auth helpers
// ---------------------------------------------------------------------------
const clearLocalSession = () => {
Cookies.remove(COOKIE_KEYS.access_token);
Cookies.remove(COOKIE_KEYS.refresh_token);
Cookies.remove(COOKIE_KEYS.user);
if (typeof window !== "undefined") {
localStorage.removeItem("auth-token");
localStorage.removeItem("user-data");
}
};
const redirectToSignIn = () => {
if (typeof window !== "undefined") {
window.location.href = SIGN_IN_PATH;
}
};
/** Logs the user out via the registered callback, falling back to a manual reset. */
const forceLogout = () => {
const logout = getLogoutCallback();
if (logout) {
logout();
return;
}
clearLocalSession();
redirectToSignIn();
};
// ---------------------------------------------------------------------------
// Error reporting
// ---------------------------------------------------------------------------
/**
* Builds the message shown in the error toast, appending the API-provided
* `details` (e.g. field-level validation hints) to the main error message
* when available.
*/
const buildErrorMessage = (data: unknown, fallback: string): string => {
const payload = data as ApiErrorData | undefined;
const message = payload?.error?.message || payload?.message || fallback;
const details = payload?.error?.details;
return details ? `${message}: ${details}` : message;
};
const notifyError = (error: unknown) => {
const data = (error as AxiosError).response?.data;
toast.error(buildErrorMessage(data, (error as Error).message));
};
// ---------------------------------------------------------------------------
// Token refresh
// ---------------------------------------------------------------------------
const requestNewAccessToken = async (refreshToken: string): Promise<string> => {
const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, {
refresh_token: refreshToken,
});
const { access_token, refresh_token } = response.data.data as TokenPair;
if (!access_token || !refresh_token) {
throw new Error("Invalid token received from refresh endpoint.");
}
Cookies.set(COOKIE_KEYS.access_token, access_token);
Cookies.set(COOKIE_KEYS.refresh_token, refresh_token);
return access_token;
};
/**
* Handles a 401 by refreshing the access token and replaying the request.
* Concurrent requests are queued until the in-flight refresh settles.
*/
const handleUnauthorized = async (originalRequest: RetriableRequest) => {
if (isRefreshing) {
return queueRequest(originalRequest);
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token);
if (!refreshToken) {
forceLogout();
return Promise.reject(new Error("Refresh token not found."));
}
const accessToken = await requestNewAccessToken(refreshToken);
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
forceLogout();
notifyError(refreshError);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
};
// ---------------------------------------------------------------------------
// Request predicates
// ---------------------------------------------------------------------------
const matchesEndpoint = (request: RetriableRequest | undefined, endpoint: string) =>
request?.url?.includes(endpoint) ?? false;
// Login errors must surface to the caller instead of triggering a refresh.
const isLoginRequest = (request?: RetriableRequest) =>
matchesEndpoint(request, API_ENDPOINTS.USER.LOGIN);
const isRefreshTokenRequest = (request?: RetriableRequest) =>
matchesEndpoint(request, API_ENDPOINTS.USER.REFRESH_TOKEN);
// ---------------------------------------------------------------------------
// Interceptors
// ---------------------------------------------------------------------------
export const responseInterceptor = (response: AxiosResponse) => response;
export const responseErrorHandler = (error: AxiosError) => {
const originalRequest = error.config as RetriableRequest | undefined;
const isUnauthorized = error.response?.status === 401;
// A 401 from the refresh endpoint itself means the session is unrecoverable.
if (isUnauthorized && isRefreshTokenRequest(originalRequest)) {
forceLogout();
return Promise.reject(error);
}
if (
isUnauthorized &&
originalRequest &&
!originalRequest._retry &&
!isLoginRequest(originalRequest)
) {
return handleUnauthorized(originalRequest);
}
notifyError(error);
return Promise.reject(error);
};