diff --git a/assets/admin/lib/api.ts b/assets/admin/lib/api.ts index 7812e997..a5521739 100644 --- a/assets/admin/lib/api.ts +++ b/assets/admin/lib/api.ts @@ -23,9 +23,22 @@ export class ApiError extends Error { } } +// چند درخواست همزمان که ۴۰۱ می‌گیرند، فقط یک‌بار refresh را اجرا کنند +let refreshPromise: Promise | null = null; + +function refreshOnce(): Promise { + if (!refreshPromise) { + refreshPromise = useAuthStore.getState().refresh().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + async function request( path: string, options: RequestInit = {}, + retry = true, ): Promise { const token = getToken(); const headers: Record = { @@ -38,6 +51,13 @@ async function request( if (!res.ok) { if (res.status === 401) { + // تلاش یک‌باره برای تازه‌سازی توکن، سپس اجرای مجدد همان درخواست + if (retry) { + const newToken = await refreshOnce(); + if (newToken) { + return request(path, options, false); + } + } useAuthStore.getState().logout(); window.location.replace('/admin/login'); throw new ApiError(401, 'ERR_UNAUTHORIZED', 'نشست منقضی شده است'); diff --git a/assets/admin/pages/LoginPage.tsx b/assets/admin/pages/LoginPage.tsx index 8231b16c..e56441ce 100644 --- a/assets/admin/pages/LoginPage.tsx +++ b/assets/admin/pages/LoginPage.tsx @@ -72,7 +72,7 @@ export default function LoginPage() { }); const json = await res.json(); if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'خطا در ورود'); return; } - login(json.access_token); + login(json.access_token, json.refresh_token); toast.success('خوش آمدید'); } catch { toast.error('خطا در اتصال به سرور'); } finally { setPwLoading(false); } @@ -121,7 +121,7 @@ export default function LoginPage() { }); const lJson = await lRes.json(); if (!lRes.ok) { toast.error(lJson?.errors?.[0]?.message ?? 'کاربری با این شماره یافت نشد'); return; } - login(lJson.access_token); + login(lJson.access_token, lJson.refresh_token); toast.success('خوش آمدید'); } catch { toast.error('خطا در اتصال به سرور'); } finally { setSmsLoading(false); } diff --git a/assets/admin/stores/authStore.ts b/assets/admin/stores/authStore.ts index b8e7d9ef..06bc627b 100644 --- a/assets/admin/stores/authStore.ts +++ b/assets/admin/stores/authStore.ts @@ -13,6 +13,7 @@ export interface ContextItem { interface AuthState { token: string | null; + refreshToken: string | null; isAuthenticated: boolean; userUuid: string | null; userName: string | null; @@ -23,8 +24,9 @@ interface AuthState { context: ContextItem | null; availableContexts: ContextItem[]; - login: (token: string) => void; + login: (token: string, refreshToken?: string | null) => void; logout: () => void; + refresh: () => Promise; fetchMe: () => Promise; switchContext: (dbUuid: string) => Promise; } @@ -56,6 +58,7 @@ export const useAuthStore = create()( persist( (set, get) => ({ token: null, + refreshToken: null, isAuthenticated: false, userUuid: null, userName: null, @@ -66,14 +69,22 @@ export const useAuthStore = create()( context: null, availableContexts: [], - login: (token) => { - set({ token, isAuthenticated: true }); + login: (token, refreshToken = null) => { + set({ token, refreshToken, isAuthenticated: true }); get().fetchMe(); }, - logout: () => + logout: () => { + const rt = get().refreshToken; + if (rt) { + apiFetch('/oauth/logout', { + method: 'POST', + body: JSON.stringify({ refresh_token: rt }), + }).catch(() => {}); + } set({ token: null, + refreshToken: null, isAuthenticated: false, userUuid: null, userName: null, @@ -83,7 +94,30 @@ export const useAuthStore = create()( doctorUuid: null, context: null, availableContexts: [], - }), + }); + }, + + refresh: async () => { + const rt = get().refreshToken; + if (!rt) return null; + try { + const res = await fetch('/oauth/token/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: rt }), + }); + const json = await res.json(); + if (!res.ok || !json?.access_token) return null; + set({ + token: json.access_token, + refreshToken: json.refresh_token ?? rt, + isAuthenticated: true, + }); + return json.access_token as string; + } catch { + return null; + } + }, fetchMe: async () => { try { @@ -125,6 +159,7 @@ export const useAuthStore = create()( name: 'clinicpro-auth', partialize: (s) => ({ token: s.token, + refreshToken: s.refreshToken, isAuthenticated: s.isAuthenticated, userUuid: s.userUuid, userName: s.userName,