da1e2f89e7
- 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.
209 lines
6.3 KiB
TypeScript
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);
|
|
};
|