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.
This commit is contained in:
@@ -1,160 +1,208 @@
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
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";
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
const SIGN_IN_PATH = "/auth/sign-in";
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
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) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
reject(error);
|
||||
} else if (token) {
|
||||
resolve(token);
|
||||
}
|
||||
});
|
||||
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
export const responseInterceptor = (response: AxiosResponse) => {
|
||||
return response;
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
export const responseErrorHandler = async (error: AxiosError) => {
|
||||
const originalRequest = error.config as any;
|
||||
const redirectToSignIn = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = SIGN_IN_PATH;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip token refresh for login endpoint - let login errors pass through
|
||||
const isLoginRequest = originalRequest?.url?.includes(
|
||||
API_ENDPOINTS.USER.LOGIN,
|
||||
);
|
||||
/** Logs the user out via the registered callback, falling back to a manual reset. */
|
||||
const forceLogout = () => {
|
||||
const logout = getLogoutCallback();
|
||||
if (logout) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
const isRefreshTokenRequest = originalRequest?.url?.includes(
|
||||
API_ENDPOINTS.USER.REFRESH_TOKEN,
|
||||
);
|
||||
clearLocalSession();
|
||||
redirectToSignIn();
|
||||
};
|
||||
|
||||
// If we get a 401 on refresh token endpoint, logout immediately (token refresh failed)
|
||||
if (error.response?.status === 401 && isRefreshTokenRequest) {
|
||||
const logout = getLogoutCallback();
|
||||
if (logout) {
|
||||
logout();
|
||||
} else {
|
||||
// Fallback if logout callback is not registered
|
||||
Cookies.remove(COOKIE_KEYS.access_token);
|
||||
Cookies.remove(COOKIE_KEYS.refresh_token);
|
||||
Cookies.remove(COOKIE_KEYS.user);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/sign-in";
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
error.response?.status === 401 &&
|
||||
isUnauthorized &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!isLoginRequest
|
||||
!isLoginRequest(originalRequest)
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token: string) => {
|
||||
originalRequest.headers["Authorization"] = "Bearer " + token;
|
||||
return api(originalRequest);
|
||||
})
|
||||
.catch((err) => {
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = Cookies.get(COOKIE_KEYS.refresh_token);
|
||||
if (!refreshToken) {
|
||||
// No refresh token available, logout immediately
|
||||
const logout = getLogoutCallback();
|
||||
if (logout) {
|
||||
logout();
|
||||
} else {
|
||||
// Fallback if logout callback is not registered
|
||||
Cookies.remove(COOKIE_KEYS.access_token);
|
||||
Cookies.remove(COOKIE_KEYS.refresh_token);
|
||||
Cookies.remove(COOKIE_KEYS.user);
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/sign-in";
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error("Refresh token not found."));
|
||||
}
|
||||
|
||||
const response = await api.post(API_ENDPOINTS.USER.REFRESH_TOKEN, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data.data as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
}
|
||||
processQueue(null, access_token);
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
|
||||
// Use logout callback from User context
|
||||
const logout = getLogoutCallback();
|
||||
if (logout) {
|
||||
logout();
|
||||
} else {
|
||||
// Fallback if logout callback is not registered
|
||||
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");
|
||||
window.location.href = "/auth/sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(
|
||||
(refreshError as AxiosError).response?.data as {
|
||||
message: string;
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
)?.error?.message || (refreshError as Error).message;
|
||||
toast.error(errorMessage);
|
||||
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
return handleUnauthorized(originalRequest);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(
|
||||
error.response?.data as {
|
||||
message: string;
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
)?.error?.message || error.message;
|
||||
toast.error(errorMessage);
|
||||
notifyError(error);
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user