feat: implement automatic JWT refresh logic with request queuing in API interceptor and auth store

This commit is contained in:
Yashwin 2026-04-27 19:15:12 +05:30
parent 7dc818ab71
commit 7eeee08318
8 changed files with 260 additions and 81 deletions

View File

@ -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
@ -78,7 +103,10 @@ export const AuthenticatedImage = ({
if (fileId) {
url = await fileService.getPreview(fileId);
} else {
const response = await apiClient.get(src!, { responseType: "blob" });
// 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);
@ -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 (
<img
src={blobUrl || src || ""}
className={className}
alt={alt}
{...props}
/>
<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={imageSrc || ""} className={className} alt={alt} {...props} />
);
};

View File

@ -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}

View File

@ -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

View File

@ -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(/\/+$/, '');
let finalUrl = faviconUrl;
// 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 = 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());

View File

@ -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>

View File

@ -45,18 +45,101 @@ 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 };
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
// Handle 401 Unauthorized errors
if (error.response?.status === 401 && !originalRequest._retry) {
const requestUrl = originalRequest.url || '';
// 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);
}
// 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 = '/';
@ -65,21 +148,16 @@ apiClient.interceptors.response.use(
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 });
}
// For auth endpoints, just reject the promise so the component can handle the error
}
return Promise.reject(error);
}
);

View File

@ -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;
},
};

View File

@ -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;