feat(auth): implement refresh token handling and update login method

This commit is contained in:
hamed
2026-06-25 17:37:55 +03:30
parent 694ee28787
commit 9b608aaeac
3 changed files with 62 additions and 7 deletions
+20
View File
@@ -23,9 +23,22 @@ export class ApiError extends Error {
} }
} }
// چند درخواست همزمان که ۴۰۱ می‌گیرند، فقط یک‌بار refresh را اجرا کنند
let refreshPromise: Promise<string | null> | null = null;
function refreshOnce(): Promise<string | null> {
if (!refreshPromise) {
refreshPromise = useAuthStore.getState().refresh().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function request<T>( async function request<T>(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
retry = true,
): Promise<T> { ): Promise<T> {
const token = getToken(); const token = getToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -38,6 +51,13 @@ async function request<T>(
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
// تلاش یک‌باره برای تازه‌سازی توکن، سپس اجرای مجدد همان درخواست
if (retry) {
const newToken = await refreshOnce();
if (newToken) {
return request<T>(path, options, false);
}
}
useAuthStore.getState().logout(); useAuthStore.getState().logout();
window.location.replace('/admin/login'); window.location.replace('/admin/login');
throw new ApiError(401, 'ERR_UNAUTHORIZED', 'نشست منقضی شده است'); throw new ApiError(401, 'ERR_UNAUTHORIZED', 'نشست منقضی شده است');
+2 -2
View File
@@ -72,7 +72,7 @@ export default function LoginPage() {
}); });
const json = await res.json(); const json = await res.json();
if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'خطا در ورود'); return; } if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'خطا در ورود'); return; }
login(json.access_token); login(json.access_token, json.refresh_token);
toast.success('خوش آمدید'); toast.success('خوش آمدید');
} catch { toast.error('خطا در اتصال به سرور'); } } catch { toast.error('خطا در اتصال به سرور'); }
finally { setPwLoading(false); } finally { setPwLoading(false); }
@@ -121,7 +121,7 @@ export default function LoginPage() {
}); });
const lJson = await lRes.json(); const lJson = await lRes.json();
if (!lRes.ok) { toast.error(lJson?.errors?.[0]?.message ?? 'کاربری با این شماره یافت نشد'); return; } if (!lRes.ok) { toast.error(lJson?.errors?.[0]?.message ?? 'کاربری با این شماره یافت نشد'); return; }
login(lJson.access_token); login(lJson.access_token, lJson.refresh_token);
toast.success('خوش آمدید'); toast.success('خوش آمدید');
} catch { toast.error('خطا در اتصال به سرور'); } } catch { toast.error('خطا در اتصال به سرور'); }
finally { setSmsLoading(false); } finally { setSmsLoading(false); }
+40 -5
View File
@@ -13,6 +13,7 @@ export interface ContextItem {
interface AuthState { interface AuthState {
token: string | null; token: string | null;
refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
userUuid: string | null; userUuid: string | null;
userName: string | null; userName: string | null;
@@ -23,8 +24,9 @@ interface AuthState {
context: ContextItem | null; context: ContextItem | null;
availableContexts: ContextItem[]; availableContexts: ContextItem[];
login: (token: string) => void; login: (token: string, refreshToken?: string | null) => void;
logout: () => void; logout: () => void;
refresh: () => Promise<string | null>;
fetchMe: () => Promise<void>; fetchMe: () => Promise<void>;
switchContext: (dbUuid: string) => Promise<void>; switchContext: (dbUuid: string) => Promise<void>;
} }
@@ -56,6 +58,7 @@ export const useAuthStore = create<AuthState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
token: null, token: null,
refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
userUuid: null, userUuid: null,
userName: null, userName: null,
@@ -66,14 +69,22 @@ export const useAuthStore = create<AuthState>()(
context: null, context: null,
availableContexts: [], availableContexts: [],
login: (token) => { login: (token, refreshToken = null) => {
set({ token, isAuthenticated: true }); set({ token, refreshToken, isAuthenticated: true });
get().fetchMe(); get().fetchMe();
}, },
logout: () => logout: () => {
const rt = get().refreshToken;
if (rt) {
apiFetch('/oauth/logout', {
method: 'POST',
body: JSON.stringify({ refresh_token: rt }),
}).catch(() => {});
}
set({ set({
token: null, token: null,
refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
userUuid: null, userUuid: null,
userName: null, userName: null,
@@ -83,7 +94,30 @@ export const useAuthStore = create<AuthState>()(
doctorUuid: null, doctorUuid: null,
context: null, context: null,
availableContexts: [], 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 () => { fetchMe: async () => {
try { try {
@@ -125,6 +159,7 @@ export const useAuthStore = create<AuthState>()(
name: 'clinicpro-auth', name: 'clinicpro-auth',
partialize: (s) => ({ partialize: (s) => ({
token: s.token, token: s.token,
refreshToken: s.refreshToken,
isAuthenticated: s.isAuthenticated, isAuthenticated: s.isAuthenticated,
userUuid: s.userUuid, userUuid: s.userUuid,
userName: s.userName, userName: s.userName,