From 7eeee08318512a987506a6254a307878979b4415 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Mon, 27 Apr 2026 19:15:12 +0530 Subject: [PATCH] feat: implement automatic JWT refresh logic with request queuing in API interceptor and auth store --- src/components/shared/AuthenticatedImage.tsx | 97 ++++++++----- src/components/shared/EditUserModal.tsx | 13 +- src/components/shared/NewUserModal.tsx | 10 +- src/hooks/useTenantTheme.ts | 13 +- src/pages/tenant/CompletionPlayground.tsx | 2 +- src/services/api-client.ts | 138 +++++++++++++++---- src/services/auth-service.ts | 19 +++ src/store/authSlice.ts | 49 ++++++- 8 files changed, 260 insertions(+), 81 deletions(-) diff --git a/src/components/shared/AuthenticatedImage.tsx b/src/components/shared/AuthenticatedImage.tsx index 2572455..fe26491 100644 --- a/src/components/shared/AuthenticatedImage.tsx +++ b/src/components/shared/AuthenticatedImage.tsx @@ -29,10 +29,44 @@ export const AuthenticatedImage = ({ alt = "Image", ...props }: AuthenticatedImageProps): ReactElement => { - const [blobUrl, setBlobUrl] = useState(null); + const cacheKey = fileId || src; + + // 1. Initialize state from cache immediately to prevent blank flash or redundant requests on remount + const [blobUrl, setBlobUrl] = useState(() => { + return cacheKey ? BLOB_CACHE.get(cacheKey) || null : null; + }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(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 tag. + // We only render if we have a valid blobUrl. + const imageSrc = isAuthRequired ? blobUrl : blobUrl || src; + + if (isAuthRequired && !imageSrc) { + return ( +
+ +
+ ); + } + return ( - {alt} + {alt} ); }; diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 1d02aa4..69c0853 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -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 = ({
setValue("department_id", value)} + onValueChange={(value) => setValue("department_id", value, { shouldValidate: true })} onLoadOptions={loadDepartments} initialOption={initialDepartmentOption || undefined} error={errors.department_id?.message} /> setValue("designation_id", value)} + onValueChange={(value) => setValue("designation_id", value, { shouldValidate: true })} onLoadOptions={loadDesignations} initialOption={initialDesignationOption || undefined} error={errors.designation_id?.message} diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index c738a22..2916cc3 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -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 = ({
- setValue("department_id", value)} onLoadOptions={loadDepartments} error={errors.department_id?.message} /> - setValue("designation_id", value)} onLoadOptions={loadDesignations} error={errors.designation_id?.message} /> + setValue("department_id", value, { shouldValidate: true })} onLoadOptions={loadDepartments} error={errors.department_id?.message} /> + setValue("designation_id", value, { shouldValidate: true })} onLoadOptions={loadDesignations} error={errors.designation_id?.message} />
{ 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()); diff --git a/src/pages/tenant/CompletionPlayground.tsx b/src/pages/tenant/CompletionPlayground.tsx index f7f82f6..42fb9bd 100644 --- a/src/pages/tenant/CompletionPlayground.tsx +++ b/src/pages/tenant/CompletionPlayground.tsx @@ -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" // /> //
diff --git a/src/services/api-client.ts b/src/services/api-client.ts index fdaac92..4f201cd 100644 --- a/src/services/api-client.ts +++ b/src/services/api-client.ts @@ -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); } ); diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index ca773eb..0742fec 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -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 => { const response = await apiClient.post('/auth/login', credentials); @@ -113,4 +128,8 @@ export const authService = { const response = await apiClient.post('/auth/reset-password', data); return response.data; }, + refreshToken: async (data: RefreshTokenRequest): Promise => { + const response = await apiClient.post('/auth/refresh', data); + return response.data; + }, }; diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index ded8cfd..1fbf76f 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -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) => { + 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;