feat: implement automatic JWT refresh logic with request queuing in API interceptor and auth store
This commit is contained in:
parent
7dc818ab71
commit
7eeee08318
@ -29,10 +29,44 @@ export const AuthenticatedImage = ({
|
||||
alt = "Image",
|
||||
...props
|
||||
}: AuthenticatedImageProps): ReactElement => {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const cacheKey = fileId || src;
|
||||
|
||||
// 1. Initialize state from cache immediately to prevent blank flash or redundant requests on remount
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(() => {
|
||||
return cacheKey ? BLOB_CACHE.get(cacheKey) || null : null;
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
// Helper to check if URL is a backend URL that needs authentication
|
||||
const getIsBackendUrl = (url: string | null | undefined) => {
|
||||
if (!url) return false;
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api/v1";
|
||||
const cleanBase = baseUrl.replace(/\/+$/, "");
|
||||
|
||||
// 1. Precise match with configured base URL
|
||||
if (url.includes(`${cleanBase}/files/`) && url.includes("/preview"))
|
||||
return true;
|
||||
|
||||
// 2. Fallback: Catch URLs pointing to any host with the expected API path structure
|
||||
// This handles cases where backend returns 'localhost' but frontend uses IP, or vice versa.
|
||||
const hasApiPath =
|
||||
url.includes("/api/v1/files/") && url.includes("/preview");
|
||||
|
||||
// Also check if it's a relative path
|
||||
const isRelative =
|
||||
url.startsWith("/") &&
|
||||
url.includes("/files/") &&
|
||||
url.includes("/preview");
|
||||
|
||||
return hasApiPath || isRelative;
|
||||
};
|
||||
|
||||
const isBackendUrl = getIsBackendUrl(src);
|
||||
const isAuthRequired = !!(fileId || isBackendUrl);
|
||||
|
||||
useEffect(() => {
|
||||
// If it's already a blob URL (local preview) or a data URL, use it directly
|
||||
if (src && (src.startsWith("blob:") || src.startsWith("data:"))) {
|
||||
@ -40,22 +74,13 @@ export const AuthenticatedImage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
|
||||
const isBackendUrl =
|
||||
src && src.includes(`${baseUrl}/files/`) && src.includes("/preview");
|
||||
|
||||
const cacheKey = fileId || src;
|
||||
if (!cacheKey) return;
|
||||
|
||||
// 1. Check if already in cache
|
||||
if (BLOB_CACHE.has(cacheKey)) {
|
||||
setBlobUrl(BLOB_CACHE.get(cacheKey)!);
|
||||
return;
|
||||
}
|
||||
// If we already have the blobUrl from initial state (cached), we don't need to fetch
|
||||
if (blobUrl) return;
|
||||
|
||||
// 2. If we have a fileId or a backend URL, fetch it via authenticated request
|
||||
if (fileId || isBackendUrl) {
|
||||
if (isAuthRequired) {
|
||||
let isMounted = true;
|
||||
const fetchImage = async () => {
|
||||
// 3. Check if there's already a pending request for this same image
|
||||
@ -74,15 +99,18 @@ export const AuthenticatedImage = ({
|
||||
setError(false);
|
||||
try {
|
||||
const fetchPromise = (async () => {
|
||||
let url: string;
|
||||
if (fileId) {
|
||||
url = await fileService.getPreview(fileId);
|
||||
} else {
|
||||
const response = await apiClient.get(src!, { responseType: "blob" });
|
||||
url = URL.createObjectURL(response.data);
|
||||
}
|
||||
BLOB_CACHE.set(cacheKey, url);
|
||||
return url;
|
||||
let url: string;
|
||||
if (fileId) {
|
||||
url = await fileService.getPreview(fileId);
|
||||
} else {
|
||||
// If useBackendUrl is true, src is guaranteed to be non-null
|
||||
const response = await apiClient.get(src!, {
|
||||
responseType: "blob",
|
||||
});
|
||||
url = URL.createObjectURL(response.data);
|
||||
}
|
||||
BLOB_CACHE.set(cacheKey, url);
|
||||
return url;
|
||||
})();
|
||||
|
||||
PENDING_REQUESTS.set(cacheKey, fetchPromise);
|
||||
@ -109,14 +137,12 @@ export const AuthenticatedImage = ({
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// NOTE: We no longer revoke the URL here because it's stored in the global BLOB_CACHE
|
||||
// for reuse when the component remounts on other pages.
|
||||
};
|
||||
} else if (src) {
|
||||
// For other external URLs, use them directly
|
||||
setBlobUrl(src);
|
||||
}
|
||||
}, [fileId, src]);
|
||||
}, [fileId, src, cacheKey, isAuthRequired, blobUrl]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -140,12 +166,21 @@ export const AuthenticatedImage = ({
|
||||
);
|
||||
}
|
||||
|
||||
// IMPORTANT: For authenticated images, never use the raw 'src' in the <img> tag.
|
||||
// We only render if we have a valid blobUrl.
|
||||
const imageSrc = isAuthRequired ? blobUrl : blobUrl || src;
|
||||
|
||||
if (isAuthRequired && !imageSrc) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center bg-[#f5f7fa] rounded-md ${className}`}
|
||||
>
|
||||
<Loader2 className="w-5 h-5 text-[#6b7280] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={blobUrl || src || ""}
|
||||
className={className}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
<img src={imageSrc || ""} className={className} alt={alt} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -51,8 +51,8 @@ const editUserSchema = z.object({
|
||||
});
|
||||
});
|
||||
}),
|
||||
department_id: z.string().optional(),
|
||||
designation_id: z.string().optional(),
|
||||
department_id: z.string().min(1, "Department is required"),
|
||||
designation_id: z.string().min(1, "Designation is required"),
|
||||
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
||||
supplier_id: z.string().optional().nullable(),
|
||||
});
|
||||
@ -400,9 +400,6 @@ export const EditUserModal = ({
|
||||
data.tenant_id = defaultTenantId;
|
||||
}
|
||||
// Normalize empty strings to null for optional UUID fields
|
||||
if (!data.department_id) data.department_id = undefined;
|
||||
if (!data.designation_id) data.designation_id = undefined;
|
||||
|
||||
// If category is tenant_user, supplier_id should always be null
|
||||
if (data.category === "tenant_user") {
|
||||
data.supplier_id = null;
|
||||
@ -556,18 +553,20 @@ export const EditUserModal = ({
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect
|
||||
label="Department"
|
||||
required
|
||||
placeholder="Select Department"
|
||||
value={departmentIdValue || ""}
|
||||
onValueChange={(value) => setValue("department_id", value)}
|
||||
onValueChange={(value) => setValue("department_id", value, { shouldValidate: true })}
|
||||
onLoadOptions={loadDepartments}
|
||||
initialOption={initialDepartmentOption || undefined}
|
||||
error={errors.department_id?.message}
|
||||
/>
|
||||
<PaginatedSelect
|
||||
label="Designation"
|
||||
required
|
||||
placeholder="Select Designation"
|
||||
value={designationIdValue || ""}
|
||||
onValueChange={(value) => setValue("designation_id", value)}
|
||||
onValueChange={(value) => setValue("designation_id", value, { shouldValidate: true })}
|
||||
onLoadOptions={loadDesignations}
|
||||
initialOption={initialDesignationOption || undefined}
|
||||
error={errors.designation_id?.message}
|
||||
|
||||
@ -50,8 +50,8 @@ const newUserSchema = z
|
||||
});
|
||||
});
|
||||
}),
|
||||
department_id: z.string().optional(),
|
||||
designation_id: z.string().optional(),
|
||||
department_id: z.string().min(1, "Department is required"),
|
||||
designation_id: z.string().min(1, "Designation is required"),
|
||||
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
||||
supplier_id: z.string().optional().nullable(),
|
||||
})
|
||||
@ -184,8 +184,6 @@ export const NewUserModal = ({
|
||||
row.module_ids.map((module_id) => ({ role_id: row.role_id, module_id })),
|
||||
);
|
||||
|
||||
if (!submitData.department_id) submitData.department_id = undefined;
|
||||
if (!submitData.designation_id) submitData.designation_id = undefined;
|
||||
if (submitData.category === "tenant_user") submitData.supplier_id = null;
|
||||
else if (!submitData.supplier_id) submitData.supplier_id = null;
|
||||
|
||||
@ -257,8 +255,8 @@ export const NewUserModal = ({
|
||||
<FormField label="Confirm Password" type="password" required placeholder="Confirm password" error={errors.confirmPassword?.message} {...register("confirmPassword")} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<PaginatedSelect label="Department" placeholder="Select Department" value={departmentIdValue || ""} onValueChange={(value) => setValue("department_id", value)} onLoadOptions={loadDepartments} error={errors.department_id?.message} />
|
||||
<PaginatedSelect label="Designation" placeholder="Select Designation" value={designationIdValue || ""} onValueChange={(value) => setValue("designation_id", value)} onLoadOptions={loadDesignations} error={errors.designation_id?.message} />
|
||||
<PaginatedSelect label="Department" required placeholder="Select Department" value={departmentIdValue || ""} onValueChange={(value) => setValue("department_id", value, { shouldValidate: true })} onLoadOptions={loadDepartments} error={errors.department_id?.message} />
|
||||
<PaginatedSelect label="Designation" required placeholder="Select Designation" value={designationIdValue || ""} onValueChange={(value) => setValue("designation_id", value, { shouldValidate: true })} onLoadOptions={loadDesignations} error={errors.designation_id?.message} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<FormSelect
|
||||
|
||||
@ -45,9 +45,14 @@ export const useTenantTheme = (): void => {
|
||||
|
||||
const applyFavicon = async () => {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
const isBackendUrl = faviconUrl.includes(`${baseUrl}/files/`) && faviconUrl.includes('/preview');
|
||||
const cleanBase = baseUrl.replace(/\/+$/, '');
|
||||
|
||||
// Robust check for backend URL (handles localhost vs IP mismatch)
|
||||
const isBackendUrl = faviconUrl.includes(`${cleanBase}/files/`) ||
|
||||
(faviconUrl.includes("/api/v1/files/") && faviconUrl.includes("/preview")) ||
|
||||
(faviconUrl.startsWith('/') && faviconUrl.includes("/files/") && faviconUrl.includes("/preview"));
|
||||
|
||||
let finalUrl = faviconUrl;
|
||||
let finalUrl = isBackendUrl ? null : faviconUrl;
|
||||
|
||||
// If it's a backend URL, fetch it with authentication
|
||||
if (isBackendUrl) {
|
||||
@ -59,11 +64,11 @@ export const useTenantTheme = (): void => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch authenticated favicon:', err);
|
||||
// Fallback to original URL, although it might still fail at browser level
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
// Only proceed if we have a valid URL to apply
|
||||
if (isMounted && finalUrl) {
|
||||
// Remove existing favicon links
|
||||
const existingFavicons = document.querySelectorAll("link[rel='icon'], link[rel='shortcut icon']");
|
||||
existingFavicons.forEach((favicon) => favicon.remove());
|
||||
|
||||
@ -117,7 +117,7 @@
|
||||
// label="Model (optional)"
|
||||
// value={form.model}
|
||||
// onChange={(e) => setForm((prev) => ({ ...prev, model: e.target.value }))}
|
||||
// placeholder="e.g. gemini-2.0-flash"
|
||||
// placeholder="e.g. gemini-2.5-flash"
|
||||
// />
|
||||
// </div>
|
||||
|
||||
|
||||
@ -45,41 +45,119 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
// Queue to store requests while token is being refreshed
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
}
|
||||
});
|
||||
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
// Response interceptor for error handling and token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Skip redirect for login/auth endpoints (401 is expected for failed login)
|
||||
const requestUrl = error.config?.url || '';
|
||||
const isAuthEndpoint = requestUrl.includes('/auth/login') || requestUrl.includes('/auth/logout');
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 Unauthorized errors
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
const requestUrl = originalRequest.url || '';
|
||||
|
||||
if (!isAuthEndpoint) {
|
||||
// Handle unauthorized - clear auth and redirect to login (only for non-auth endpoints)
|
||||
// Check if user is on a tenant route to determine redirect path
|
||||
const isTenantRoute = window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant';
|
||||
|
||||
let redirectPath = '/';
|
||||
try {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
if (store) {
|
||||
const state = store.getState() as RootState;
|
||||
const isSuperAdmin = state.auth.roles.includes('super_admin');
|
||||
|
||||
// Super admins always go to root login, tenant users go to /tenant/login if on a tenant route
|
||||
redirectPath = isSuperAdmin ? '/' : (isTenantRoute ? '/tenant/login' : '/');
|
||||
|
||||
store.dispatch({ type: 'auth/logout' });
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback if store is not available
|
||||
redirectPath = isTenantRoute ? '/tenant/login' : '/';
|
||||
}
|
||||
|
||||
navigate(redirectPath, { replace: true });
|
||||
// Skip refresh for auth endpoints to avoid loops
|
||||
const isAuthEndpoint =
|
||||
requestUrl.includes('/auth/login') ||
|
||||
requestUrl.includes('/auth/logout') ||
|
||||
requestUrl.includes('/auth/refresh');
|
||||
|
||||
if (isAuthEndpoint) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// For auth endpoints, just reject the promise so the component can handle the error
|
||||
|
||||
// If already refreshing, queue this request
|
||||
if (isRefreshing) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
failedQueue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
})
|
||||
.catch((err) => {
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
if (store) {
|
||||
const state = store.getState() as RootState;
|
||||
const refreshToken = state?.auth?.refreshToken;
|
||||
|
||||
if (refreshToken) {
|
||||
// Call refresh API directly using axios to avoid cycles and interceptors
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
const { access_token } = response.data.data;
|
||||
|
||||
// Update store with new tokens
|
||||
// We use action type string to avoid circular dependency with authSlice
|
||||
store.dispatch({
|
||||
type: 'auth/setTokens',
|
||||
payload: response.data.data
|
||||
});
|
||||
|
||||
processQueue(null, access_token);
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
}
|
||||
|
||||
isRefreshing = false;
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
|
||||
// If we reach here, refresh failed or was not possible - redirect to login
|
||||
const isTenantRoute = window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant';
|
||||
|
||||
let redirectPath = '/';
|
||||
try {
|
||||
const store = (window as any).__REDUX_STORE__;
|
||||
if (store) {
|
||||
const state = store.getState() as RootState;
|
||||
const isSuperAdmin = state.auth.roles.includes('super_admin');
|
||||
redirectPath = isSuperAdmin ? '/' : (isTenantRoute ? '/tenant/login' : '/');
|
||||
store.dispatch({ type: 'auth/logout' });
|
||||
}
|
||||
} catch (e) {
|
||||
redirectPath = isTenantRoute ? '/tenant/login' : '/';
|
||||
}
|
||||
|
||||
navigate(redirectPath, { replace: true });
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@ -95,6 +95,21 @@ export interface ResetPasswordResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
expires_at: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
|
||||
@ -113,4 +128,8 @@ export const authService = {
|
||||
const response = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', data);
|
||||
return response.data;
|
||||
},
|
||||
refreshToken: async (data: RefreshTokenRequest): Promise<RefreshTokenResponse> => {
|
||||
const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import { authService, type LoginRequest, type LoginResponse, type LoginError, type GeneralError, type Permission } from '@/services/auth-service';
|
||||
import { authService, type LoginRequest, type LoginResponse, type LoginError, type GeneralError, type Permission, type RefreshTokenResponse } from '@/services/auth-service';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@ -78,6 +78,23 @@ export const logoutAsync = createAsyncThunk<{ message?: string }, void, { reject
|
||||
}
|
||||
});
|
||||
|
||||
// Async thunk for refresh token
|
||||
export const refreshTokenAsync = createAsyncThunk<
|
||||
RefreshTokenResponse['data'],
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>('auth/refreshToken', async (refreshToken, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await authService.refreshToken({ refresh_token: refreshToken });
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
}
|
||||
return rejectWithValue('Token refresh failed');
|
||||
} catch (error: any) {
|
||||
return rejectWithValue(error.message || 'Token refresh failed');
|
||||
}
|
||||
});
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
@ -99,6 +116,13 @@ const authSlice = createSlice({
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
setTokens: (state, action: PayloadAction<{ access_token: string; refresh_token: string; expires_in: number; expires_at: string; token_type: string }>) => {
|
||||
state.accessToken = action.payload.access_token;
|
||||
state.refreshToken = action.payload.refresh_token;
|
||||
state.tokenType = action.payload.token_type;
|
||||
state.expiresIn = action.payload.expires_in;
|
||||
state.expiresAt = action.payload.expires_at;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
@ -171,9 +195,30 @@ const authSlice = createSlice({
|
||||
state.isAuthenticated = false;
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(refreshTokenAsync.fulfilled, (state, action: PayloadAction<RefreshTokenResponse['data']>) => {
|
||||
state.accessToken = action.payload.access_token;
|
||||
state.refreshToken = action.payload.refresh_token;
|
||||
state.tokenType = action.payload.token_type;
|
||||
state.expiresIn = action.payload.expires_in;
|
||||
state.expiresAt = action.payload.expires_at;
|
||||
})
|
||||
.addCase(refreshTokenAsync.rejected, (state) => {
|
||||
// If refresh fails, we should logout
|
||||
state.user = null;
|
||||
state.tenantId = null;
|
||||
state.roles = [];
|
||||
state.permissions = [];
|
||||
state.accessToken = null;
|
||||
state.refreshToken = null;
|
||||
state.tokenType = null;
|
||||
state.expiresIn = null;
|
||||
state.expiresAt = null;
|
||||
state.isAuthenticated = false;
|
||||
state.isLoading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { logout, clearError } = authSlice.actions;
|
||||
export const { logout, clearError, setTokens } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user