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",
|
alt = "Image",
|
||||||
...props
|
...props
|
||||||
}: AuthenticatedImageProps): ReactElement => {
|
}: 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 [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [error, setError] = 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(() => {
|
useEffect(() => {
|
||||||
// If it's already a blob URL (local preview) or a data URL, use it directly
|
// If it's already a blob URL (local preview) or a data URL, use it directly
|
||||||
if (src && (src.startsWith("blob:") || src.startsWith("data:"))) {
|
if (src && (src.startsWith("blob:") || src.startsWith("data:"))) {
|
||||||
@ -40,22 +74,13 @@ export const AuthenticatedImage = ({
|
|||||||
return;
|
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;
|
if (!cacheKey) return;
|
||||||
|
|
||||||
// 1. Check if already in cache
|
// If we already have the blobUrl from initial state (cached), we don't need to fetch
|
||||||
if (BLOB_CACHE.has(cacheKey)) {
|
if (blobUrl) return;
|
||||||
setBlobUrl(BLOB_CACHE.get(cacheKey)!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If we have a fileId or a backend URL, fetch it via authenticated request
|
// 2. If we have a fileId or a backend URL, fetch it via authenticated request
|
||||||
if (fileId || isBackendUrl) {
|
if (isAuthRequired) {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
const fetchImage = async () => {
|
const fetchImage = async () => {
|
||||||
// 3. Check if there's already a pending request for this same image
|
// 3. Check if there's already a pending request for this same image
|
||||||
@ -78,7 +103,10 @@ export const AuthenticatedImage = ({
|
|||||||
if (fileId) {
|
if (fileId) {
|
||||||
url = await fileService.getPreview(fileId);
|
url = await fileService.getPreview(fileId);
|
||||||
} else {
|
} 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);
|
url = URL.createObjectURL(response.data);
|
||||||
}
|
}
|
||||||
BLOB_CACHE.set(cacheKey, url);
|
BLOB_CACHE.set(cacheKey, url);
|
||||||
@ -109,14 +137,12 @@ export const AuthenticatedImage = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
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) {
|
} else if (src) {
|
||||||
// For other external URLs, use them directly
|
// For other external URLs, use them directly
|
||||||
setBlobUrl(src);
|
setBlobUrl(src);
|
||||||
}
|
}
|
||||||
}, [fileId, src]);
|
}, [fileId, src, cacheKey, isAuthRequired, blobUrl]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<img
|
<div
|
||||||
src={blobUrl || src || ""}
|
className={`flex items-center justify-center bg-[#f5f7fa] rounded-md ${className}`}
|
||||||
className={className}
|
>
|
||||||
alt={alt}
|
<Loader2 className="w-5 h-5 text-[#6b7280] animate-spin" />
|
||||||
{...props}
|
</div>
|
||||||
/>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img src={imageSrc || ""} className={className} alt={alt} {...props} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -51,8 +51,8 @@ const editUserSchema = z.object({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
department_id: z.string().optional(),
|
department_id: z.string().min(1, "Department is required"),
|
||||||
designation_id: z.string().optional(),
|
designation_id: z.string().min(1, "Designation is required"),
|
||||||
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
||||||
supplier_id: z.string().optional().nullable(),
|
supplier_id: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
@ -400,9 +400,6 @@ export const EditUserModal = ({
|
|||||||
data.tenant_id = defaultTenantId;
|
data.tenant_id = defaultTenantId;
|
||||||
}
|
}
|
||||||
// Normalize empty strings to null for optional UUID fields
|
// 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 category is tenant_user, supplier_id should always be null
|
||||||
if (data.category === "tenant_user") {
|
if (data.category === "tenant_user") {
|
||||||
data.supplier_id = null;
|
data.supplier_id = null;
|
||||||
@ -556,18 +553,20 @@ export const EditUserModal = ({
|
|||||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||||
<PaginatedSelect
|
<PaginatedSelect
|
||||||
label="Department"
|
label="Department"
|
||||||
|
required
|
||||||
placeholder="Select Department"
|
placeholder="Select Department"
|
||||||
value={departmentIdValue || ""}
|
value={departmentIdValue || ""}
|
||||||
onValueChange={(value) => setValue("department_id", value)}
|
onValueChange={(value) => setValue("department_id", value, { shouldValidate: true })}
|
||||||
onLoadOptions={loadDepartments}
|
onLoadOptions={loadDepartments}
|
||||||
initialOption={initialDepartmentOption || undefined}
|
initialOption={initialDepartmentOption || undefined}
|
||||||
error={errors.department_id?.message}
|
error={errors.department_id?.message}
|
||||||
/>
|
/>
|
||||||
<PaginatedSelect
|
<PaginatedSelect
|
||||||
label="Designation"
|
label="Designation"
|
||||||
|
required
|
||||||
placeholder="Select Designation"
|
placeholder="Select Designation"
|
||||||
value={designationIdValue || ""}
|
value={designationIdValue || ""}
|
||||||
onValueChange={(value) => setValue("designation_id", value)}
|
onValueChange={(value) => setValue("designation_id", value, { shouldValidate: true })}
|
||||||
onLoadOptions={loadDesignations}
|
onLoadOptions={loadDesignations}
|
||||||
initialOption={initialDesignationOption || undefined}
|
initialOption={initialDesignationOption || undefined}
|
||||||
error={errors.designation_id?.message}
|
error={errors.designation_id?.message}
|
||||||
|
|||||||
@ -50,8 +50,8 @@ const newUserSchema = z
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
department_id: z.string().optional(),
|
department_id: z.string().min(1, "Department is required"),
|
||||||
designation_id: z.string().optional(),
|
designation_id: z.string().min(1, "Designation is required"),
|
||||||
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
||||||
supplier_id: z.string().optional().nullable(),
|
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 })),
|
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;
|
if (submitData.category === "tenant_user") submitData.supplier_id = null;
|
||||||
else if (!submitData.supplier_id) 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")} />
|
<FormField label="Confirm Password" type="password" required placeholder="Confirm password" error={errors.confirmPassword?.message} {...register("confirmPassword")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
<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="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" placeholder="Select Designation" value={designationIdValue || ""} onValueChange={(value) => setValue("designation_id", value)} onLoadOptions={loadDesignations} error={errors.designation_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>
|
||||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||||
<FormSelect
|
<FormSelect
|
||||||
|
|||||||
@ -45,9 +45,14 @@ export const useTenantTheme = (): void => {
|
|||||||
|
|
||||||
const applyFavicon = async () => {
|
const applyFavicon = async () => {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
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 it's a backend URL, fetch it with authentication
|
||||||
if (isBackendUrl) {
|
if (isBackendUrl) {
|
||||||
@ -59,11 +64,11 @@ export const useTenantTheme = (): void => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch authenticated favicon:', 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
|
// Remove existing favicon links
|
||||||
const existingFavicons = document.querySelectorAll("link[rel='icon'], link[rel='shortcut icon']");
|
const existingFavicons = document.querySelectorAll("link[rel='icon'], link[rel='shortcut icon']");
|
||||||
existingFavicons.forEach((favicon) => favicon.remove());
|
existingFavicons.forEach((favicon) => favicon.remove());
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
// label="Model (optional)"
|
// label="Model (optional)"
|
||||||
// value={form.model}
|
// value={form.model}
|
||||||
// onChange={(e) => setForm((prev) => ({ ...prev, model: e.target.value }))}
|
// onChange={(e) => setForm((prev) => ({ ...prev, model: e.target.value }))}
|
||||||
// placeholder="e.g. gemini-2.0-flash"
|
// placeholder="e.g. gemini-2.5-flash"
|
||||||
// />
|
// />
|
||||||
// </div>
|
// </div>
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
// 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');
|
|
||||||
|
|
||||||
if (!isAuthEndpoint) {
|
// Handle 401 Unauthorized errors
|
||||||
// Handle unauthorized - clear auth and redirect to login (only for non-auth endpoints)
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
// Check if user is on a tenant route to determine redirect path
|
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';
|
const isTenantRoute = window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant';
|
||||||
|
|
||||||
let redirectPath = '/';
|
let redirectPath = '/';
|
||||||
@ -65,21 +148,16 @@ apiClient.interceptors.response.use(
|
|||||||
if (store) {
|
if (store) {
|
||||||
const state = store.getState() as RootState;
|
const state = store.getState() as RootState;
|
||||||
const isSuperAdmin = state.auth.roles.includes('super_admin');
|
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' : '/');
|
redirectPath = isSuperAdmin ? '/' : (isTenantRoute ? '/tenant/login' : '/');
|
||||||
|
|
||||||
store.dispatch({ type: 'auth/logout' });
|
store.dispatch({ type: 'auth/logout' });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback if store is not available
|
|
||||||
redirectPath = isTenantRoute ? '/tenant/login' : '/';
|
redirectPath = isTenantRoute ? '/tenant/login' : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(redirectPath, { replace: true });
|
navigate(redirectPath, { replace: true });
|
||||||
}
|
}
|
||||||
// For auth endpoints, just reject the promise so the component can handle the error
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -95,6 +95,21 @@ export interface ResetPasswordResponse {
|
|||||||
message?: string;
|
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 = {
|
export const authService = {
|
||||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||||
const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
|
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);
|
const response = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', data);
|
||||||
return response.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 { 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 {
|
interface User {
|
||||||
id: string;
|
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({
|
const authSlice = createSlice({
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
initialState,
|
initialState,
|
||||||
@ -99,6 +116,13 @@ const authSlice = createSlice({
|
|||||||
clearError: (state) => {
|
clearError: (state) => {
|
||||||
state.error = null;
|
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) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
@ -171,9 +195,30 @@ const authSlice = createSlice({
|
|||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
state.error = null;
|
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;
|
export default authSlice.reducer;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user