diff --git a/src/app/(login_register)/login/page.tsx b/src/app/(login_register)/login/page.tsx index bc9e33c..edd1db4 100644 --- a/src/app/(login_register)/login/page.tsx +++ b/src/app/(login_register)/login/page.tsx @@ -1,46 +1,7 @@ 'use client' -import { FormEvent, useEffect, useState } from 'react' -import axios from 'axios' -import { useRouter } from 'next/navigation' -import { useDispatch } from 'react-redux' -import { addMobile } from '@/app/redux/features/mobileoptSlice' -import { apiErrorMessage } from '@/lib/auth' +import CustomerLoginForm from '@/components/auth/customer-login-form' export default function Page() { - const [mobile, setMobile] = useState('') - const [error, setError] = useState('') - const [busy, setBusy] = useState(false) - const router = useRouter() - const dispatch = useDispatch() - useEffect(() => { - const reason = new URLSearchParams(window.location.search).get('reason') - if (reason === 'logout-unconfirmed') setError('از این دستگاه خارج شدید؛ به علت قطع ارتباط، ابطال نشست در سرور تأیید نشد.') - if (reason === 'session') setError('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.') - }, []) - - async function login(event: FormEvent) { - event.preventDefault() - setError('') - setBusy(true) - try { - const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/', { mobile }, { timeout: 15000 }) - const number = response.data?.result?.mobile - if (!number) throw new Error('پاسخ ارسال کد معتبر نیست. دوباره تلاش کنید.') - dispatch(addMobile(number)) - window.sessionStorage.setItem('loginMobile', String(number)) - router.push('/login/verify/') - } catch (failure) { setError(apiErrorMessage(failure)) } - finally { setBusy(false) } - } - - return
-
-

ورود به سامانه مشتریان

- - setMobile(e.target.value)} placeholder="شماره موبایل را وارد کنید" className="w-full h-[45px] p-5 rounded-md shadow-sm" /> - {error &&

{error}

} - -
-
+ return } diff --git a/src/app/(login_register)/login/verify/page.tsx b/src/app/(login_register)/login/verify/page.tsx index 230db15..c133f48 100644 --- a/src/app/(login_register)/login/verify/page.tsx +++ b/src/app/(login_register)/login/verify/page.tsx @@ -1,62 +1,7 @@ 'use client' -import { FormEvent, useEffect, useState } from 'react' -import axios from 'axios' -import { useSelector } from 'react-redux' -import { RootState } from '@/app/redux/store' -import { useRouter } from 'next/navigation' -import { apiErrorMessage, LoginOption, obtainTokens } from '@/lib/auth' +import CustomerLoginForm from '@/components/auth/customer-login-form' export default function Page() { - const router = useRouter() - const reduxMobile = useSelector((state: RootState) => state.mobileopt.mobile) - const [mobile, setMobile] = useState('') - const [code, setCode] = useState('') - const [error, setError] = useState('') - const [busy, setBusy] = useState(false) - const [options, setOptions] = useState([]) - useEffect(() => { - const value = reduxMobile || window.sessionStorage.getItem('loginMobile') - if (!value) router.replace('/login') - else setMobile(String(value)) - }, [reduxMobile, router]) - - async function choose(option: LoginOption) { - setBusy(true) - setError('') - try { - await obtainTokens(option.uuid) - window.sessionStorage.removeItem('loginMobile') - window.location.replace('/shop') - } catch (failure) { setError(apiErrorMessage(failure)) } - finally { setBusy(false) } - } - - async function verify(event: FormEvent) { - event.preventDefault() - if (!mobile) return - const digits = code.replace(/[۰-۹]/g, digit => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(digit))).replace(/[٠-٩]/g, digit => String('٠١٢٣٤٥٦٧٨٩'.indexOf(digit))) - if (!/^\d{6}$/.test(digits)) { setError('کد شش‌رقمی پیامک را وارد کنید.'); return } - setBusy(true) - setError('') - try { - const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/verify/', { mobile, opt: digits }, { timeout: 10000 }) - const roles: LoginOption[] = response.data - if (!Array.isArray(roles) || !roles.length) throw new Error('دسترسی فعالی برای ورود وجود ندارد.') - setOptions(roles) - if (roles.length === 1) await choose(roles[0]) - } catch (failure) { setError(apiErrorMessage(failure)) } - finally { setBusy(false) } - } - - return
-
-

ورود به سامانه مشتریان

-

کد تایید پیامک‌شده را وارد کنید

- {!options.length && <> setCode(e.target.value)} maxLength={6} required className="w-full h-[45px] p-5 rounded-md shadow-sm" />} - {options.length > 0 &&

پنل موردنظر را انتخاب کنید:

{options.map(option => )}
} - {error &&

{error}

} - -
-
+ return } diff --git a/src/app/components/layout/customer-info.tsx b/src/app/components/layout/customer-info.tsx index ddbab02..c73e823 100644 --- a/src/app/components/layout/customer-info.tsx +++ b/src/app/components/layout/customer-info.tsx @@ -1,27 +1,31 @@ import axiosInstance from "@/axios/axiosInstance" import { useQuery } from "@tanstack/react-query" -import { useState } from "react" +import { useRef, useState } from "react" import { logout } from '@/lib/auth' const CustomerInfo = ()=>{ - const [nameFamily, setNameFamily] = useState(null) + const [leaving, setLeaving] = useState(false) + const logoutStarted = useRef(false) - const onLogout = () => { void logout() } + const onLogout = () => { + if (logoutStarted.current) return + logoutStarted.current = true + setLeaving(true) + void logout() + } const query = useQuery({ queryKey: ['cuser-info-x1'], queryFn: async ()=>{ return await axiosInstance.get("cuser/info/") - },refetchOnWindowFocus: false,onSuccess(result:any) { - setNameFamily(result.data.name_family) - }, }) + }, retry: false, staleTime: 30000, refetchOnWindowFocus: false }) return (
-
{nameFamily}
-
خروج
+
{query.data?.data.name_family}
+
) } -export default CustomerInfo \ No newline at end of file +export default CustomerInfo diff --git a/src/app/components/layout/main-layout-util.tsx b/src/app/components/layout/main-layout-util.tsx index eebd874..b167b06 100644 --- a/src/app/components/layout/main-layout-util.tsx +++ b/src/app/components/layout/main-layout-util.tsx @@ -1,38 +1,27 @@ 'use client' -import ReduxProvider from "@/app/redux/provider" -import HamberMenu from "./hamber-menu" -import ReactQueryProvider from "@/app/react_query/provider" +import ReduxProvider from '@/app/redux/provider' +import ReactQueryProvider from '@/app/react_query/provider' +import SessionGuard from '@/components/auth/session-guard' import { usePathname } from 'next/navigation' -import CustomerInfo from "./customer-info" +import HamberMenu from './hamber-menu' +import CustomerInfo from './customer-info' - -const MainLayoutUtil = ({children,}: {children: React.ReactNode})=>{ +const MainLayoutUtil = ({ children }: { children: React.ReactNode }) => { const pathname = usePathname() - - return ( -
- + return
- -
- - - -
- -
- - -
- - {children} - -
+ + +
+ +
+
+ {children} +
+
-
- ) +
} - -export default MainLayoutUtil \ No newline at end of file +export default MainLayoutUtil diff --git a/src/axios/axiosInstance.ts b/src/axios/axiosInstance.ts index 8074448..c448ea7 100644 --- a/src/axios/axiosInstance.ts +++ b/src/axios/axiosInstance.ts @@ -1,64 +1,123 @@ 'use client' import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' -import { expireSession, getAccessToken, getRefreshToken, saveTokens } from '@/lib/auth' +import { expireSession, getAccessToken, getRefreshToken, getSessionIdentity, saveTokens, validTokens } from '@/lib/auth' -type RetryRequest = InternalAxiosRequestConfig & { _retry?: boolean } +type RetryRequest = InternalAxiosRequestConfig & { _retry?: boolean; _sessionIdentity?: string } class InvalidSession extends Error {} -let pendingRefresh: Promise | null = null +let pendingRefresh: { identity: string; promise: Promise } | null = null +const leaseKey = 'customerRefreshLease' +type RefreshLease = { owner: string; identity: string; expires: number } + +function readLease(): RefreshLease | null { + try { + const value = JSON.parse(window.localStorage.getItem(leaseKey) || 'null') + return value && typeof value.owner === 'string' && typeof value.identity === 'string' && + typeof value.expires === 'number' && Number.isFinite(value.expires) ? value : null + } catch { return null } +} +async function withRefreshLease(identity: string, action: () => Promise): Promise { + const owner = `${Date.now()}-${Math.random()}` + const deadline = Date.now() + 20000 + while (Date.now() < deadline) { + if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') + const lease = readLease() + if (!lease || lease.identity !== identity || lease.expires <= Date.now()) { + try { window.localStorage.setItem(leaseKey, JSON.stringify({ owner, identity, expires: Date.now() + 15000 })) } + catch { return action() } + // Yield before checking ownership so simultaneous contenders see + // the same winner rather than both sending a rotating JWT. + await new Promise(resolve => setTimeout(resolve, 50)) + if (readLease()?.owner === owner) { + try { return await action() } + finally { + try { if (readLease()?.owner === owner) window.localStorage.removeItem(leaseKey) } + catch { /* An abandoned lease expires, including after a closed tab. */ } + } + } + } + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error('تمدید نشست موقتاً انجام نشد. دوباره تلاش کنید.') +} const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL + '/', timeout: 10000, headers: { 'Content-Type': 'application/json' }, }) - -axiosInstance.interceptors.request.use(request => { +axiosInstance.interceptors.request.use((request: RetryRequest) => { const access = getAccessToken() if (access) request.headers.Authorization = 'Bearer ' + access else delete request.headers.Authorization + request._sessionIdentity ??= getSessionIdentity() return request }) -async function refreshAccess(): Promise { - const refresh = getRefreshToken() - if (!refresh) throw new InvalidSession() - try { - const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/refresh/', { refresh }, { timeout: 10000 }) - if (getRefreshToken() !== refresh) throw new Error('نشست تغییر کرده است.') - if (!response.data?.access || !response.data?.refresh) throw new InvalidSession() - saveTokens(response.data) - return response.data.access - } catch (error) { - if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession() - throw error +async function refreshAccess(identity: string): Promise { + const originalRefresh = getRefreshToken() + async function rotate(): Promise { + if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') + const refresh = getRefreshToken() + if (!refresh) throw new InvalidSession() + // A different tab may have rotated while this tab waited for the lock. + if (refresh !== originalRefresh && getAccessToken()) return getAccessToken()! + try { + const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/refresh/', { refresh }, { timeout: 10000 }) + if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') + if (getRefreshToken() !== refresh) { + if (getAccessToken()) return getAccessToken()! + throw new Error('نشست تغییر کرده است.') + } + if (!validTokens(response.data)) throw new InvalidSession() + saveTokens(response.data, refresh) + return response.data.access + } catch (error) { + if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') + if (getRefreshToken() !== refresh && getAccessToken()) return getAccessToken()! + if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession() + throw error + } } + // Web Locks serialize refresh rotation across tabs in supporting browsers. + if (typeof navigator !== 'undefined' && navigator.locks) { + return navigator.locks.request('crm-customer-jwt-refresh', rotate) + } + return withRefreshLease(identity, rotate) } -axiosInstance.interceptors.response.use(response => response, async (error: AxiosError) => { +axiosInstance.interceptors.response.use(response => { + const identity = (response.config as RetryRequest)._sessionIdentity + if (identity && identity !== getSessionIdentity()) return Promise.reject(new Error('نشست تغییر کرده است.')) + return response +}, async (error: AxiosError) => { const request = error.config as RetryRequest | undefined if (error.response?.status !== 401 || !request || /\/auth\//.test(request.url || '')) return Promise.reject(error) + const identity = request._sessionIdentity! + // Never replay a previous person's request under a newly logged-in account. + if (identity !== getSessionIdentity()) return Promise.reject(error) if (request._retry) { const latest = getAccessToken() if (!latest || request.headers.Authorization === 'Bearer ' + latest) expireSession() return Promise.reject(error) } request._retry = true - // Another request may already have refreshed the token that this request used. const currentAccess = getAccessToken() - if (currentAccess && request.headers.Authorization !== 'Bearer ' + currentAccess) { - request.headers.Authorization = 'Bearer ' + currentAccess - return axiosInstance(request) - } + if (currentAccess && request.headers.Authorization !== 'Bearer ' + currentAccess) return axiosInstance(request) const refresh = getRefreshToken() try { - if (!pendingRefresh) pendingRefresh = refreshAccess().finally(() => { pendingRefresh = null }) - request.headers.Authorization = 'Bearer ' + await pendingRefresh + if (!pendingRefresh || pendingRefresh.identity !== identity) { + const pending = { identity, promise: refreshAccess(identity) } + pendingRefresh = pending + void pending.promise.finally(() => { if (pendingRefresh === pending) pendingRefresh = null }).catch(() => {}) + } + const access = await pendingRefresh.promise + if (identity !== getSessionIdentity()) return Promise.reject(error) + request.headers.Authorization = 'Bearer ' + access return axiosInstance(request) } catch (refreshError) { - if (refreshError instanceof InvalidSession && getRefreshToken() === refresh) expireSession() + if (refreshError instanceof InvalidSession && identity === getSessionIdentity() && getRefreshToken() === refresh) expireSession() return Promise.reject(refreshError) } }) - export default axiosInstance diff --git a/src/components/auth/customer-login-form.tsx b/src/components/auth/customer-login-form.tsx new file mode 100644 index 0000000..eed4096 --- /dev/null +++ b/src/components/auth/customer-login-form.tsx @@ -0,0 +1,159 @@ +'use client' + +import { FormEvent, useEffect, useRef, useState } from 'react' +import axios from 'axios' +import { apiErrorMessage, forgetLoginMobile, LoginOption, obtainTokens, readLoginMobile, rememberLoginMobile } from '@/lib/auth' + +type Step = 'mobile' | 'verify' +function normalizePhone(value: string): string { + let digits = value.trim().replace(/[۰-۹]/g, n => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(n))) + .replace(/[٠-٩]/g, n => String('٠١٢٣٤٥٦٧٨٩'.indexOf(n))).replace(/[\s-]/g, '') + for (const prefix of ['+98', '0098', '98', '0']) { + if (digits.startsWith(prefix) && digits.length === 10 + prefix.length) { digits = digits.slice(prefix.length); break } + } + return /^9\d{9}$/.test(digits) ? digits : '' +} +function loginOptions(data: unknown): LoginOption[] { + if (!Array.isArray(data)) return [] + return data.filter(option => typeof option?.uuid === 'string' && option.uuid && + option?.cuser_role?.role_type?.role_name_en === 'workspace_customer' && + typeof option?.cuser_role?.sys_ws?.name === 'string') +} + +export default function CustomerLoginForm({ initialStep = 'mobile' }: { initialStep?: Step }) { + const [mobile, setMobile] = useState(() => initialStep === 'verify' ? normalizePhone(readLoginMobile()) : '') + const [step, setStep] = useState(() => initialStep === 'verify' && normalizePhone(readLoginMobile()) ? 'verify' : 'mobile') + const [code, setCode] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [options, setOptions] = useState([]) + const [recoveryMobile, setRecoveryMobile] = useState('') + const [resendAt, setResendAt] = useState(0) + const [now, setNow] = useState(Date.now()) + const locked = useRef(false) + const mounted = useRef(true) + const redirecting = useRef(false) + const remaining = Math.max(0, Math.ceil((resendAt - now) / 1000)) + useEffect(() => { + mounted.current = true + const reason = new URLSearchParams(window.location.search).get('reason') + if (reason === 'logout-unconfirmed') setError('از این دستگاه خارج شدید؛ به علت قطع ارتباط، ابطال نشست در سرور تأیید نشد.') + if (reason === 'session') setError('نشست شما منقضی یا باطل شده است. دوباره وارد شوید.') + return () => { mounted.current = false } + }, []) + useEffect(() => { + if (!resendAt) return + const timer = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(timer) + }, [resendAt]) + + async function run(action: () => Promise) { + if (locked.current) return + locked.current = true + setBusy(true) + setError('') + try { await action() } + catch (failure) { + if (!mounted.current) return + setError(apiErrorMessage(failure)) + if (axios.isAxiosError(failure) && failure.response?.status === 429) { + const wait = Number(failure.response.data?.retry_after || failure.response.headers?.['retry-after'] || 60) + if (Number.isFinite(wait) && wait > 0) setResendAt(Date.now() + Math.min(wait, 3600) * 1000) + } + } finally { + if (!redirecting.current) { + if (mounted.current) setBusy(false) + locked.current = false + } + } + } + async function sendCode(event?: FormEvent) { + event?.preventDefault() + if (locked.current || Date.now() < resendAt) return + const normalized = normalizePhone(mobile) + if (!normalized) { setError('شماره موبایل معتبر وارد کنید.'); return } + await run(async () => { + setRecoveryMobile('') + let response + try { + response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/', { mobile: normalized }, { timeout: 30000 }) + } catch (failure) { + // The server may have sent the SMS even if its HTTP response was + // lost. Let the person use the code they actually received. + if (mounted.current && axios.isAxiosError(failure) && (!failure.response || failure.response.status === 429)) setRecoveryMobile(normalized) + throw failure + } + const confirmed = normalizePhone(String(response.data?.result?.mobile || '')) + if (!confirmed) throw new Error('پاسخ ارسال کد معتبر نیست. دوباره تلاش کنید.') + if (!mounted.current) return + rememberLoginMobile(confirmed) + setMobile(confirmed) + setCode('') + setOptions([]) + setStep('verify') // Never depend on storage or route navigation after SMS. + const wait = Number(response.data?.result?.retry_after || 60) + setNow(Date.now()) + setResendAt(Date.now() + (Number.isFinite(wait) ? Math.min(Math.max(wait, 1), 3600) : 60) * 1000) + }) + } + async function finishLogin(option: LoginOption) { + await obtainTokens(option.uuid) + if (!mounted.current) return + forgetLoginMobile() + redirecting.current = true + window.location.replace('/shop') + } + async function verify(event: FormEvent) { + event.preventDefault() + if (locked.current || options.length) return + const digits = code.replace(/[۰-۹]/g, n => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(n))) + .replace(/[٠-٩]/g, n => String('٠١٢٣٤٥٦٧٨٩'.indexOf(n))) + if (!/^[1-9]\d{5}$/.test(digits)) { setError('کد شش‌رقمی پیامک را وارد کنید.'); return } + await run(async () => { + const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/login/mobile/verify/', { mobile, opt: digits }, { timeout: 15000 }) + const roles = loginOptions(response.data) + if (!roles.length) throw new Error('دسترسی فعالی به پنل مشتریان برای ورود وجود ندارد.') + if (!mounted.current) return + setOptions(roles) + if (roles.length === 1) await finishLogin(roles[0]) + }) + } + function changeMobile() { + if (locked.current) return + forgetLoginMobile() + setStep('mobile') + setCode('') + setOptions([]) + setError('') + } + return
+
+

ورود به سامانه مشتریان

+ {step === 'mobile' ? <> + + setMobile(e.target.value)} placeholder="شماره موبایل را وارد کنید" className="w-full h-[45px] p-5 rounded-md shadow-sm" /> + + {recoveryMobile && } + : <> +

کد شش‌رقمی پیامک‌شده به شمارهٔ 0{mobile} را وارد کنید.

+ {!options.length ? <> + + setCode(e.target.value)} maxLength={6} required className="w-full h-[45px] p-5 rounded-md shadow-sm" /> + + :

پنل موردنظر را انتخاب کنید:

{options.map(option => )}
} + + + } + {remaining > 0 &&

ارسال دوبارهٔ کد پس از {remaining} ثانیه امکان‌پذیر است.

} + {error &&

{error}

} +
+
+} diff --git a/src/components/auth/session-guard.tsx b/src/components/auth/session-guard.tsx new file mode 100644 index 0000000..4a6fd48 --- /dev/null +++ b/src/components/auth/session-guard.tsx @@ -0,0 +1,51 @@ +'use client' + +import { ReactNode, useEffect, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import axiosInstance from '@/axios/axiosInstance' +import { apiErrorMessage, expireSession, getAccessToken, getRefreshToken, getSessionIdentity } from '@/lib/auth' + +export default function SessionGuard({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false) + const client = useQueryClient() + useEffect(() => { + if (!getAccessToken() && !getRefreshToken()) { expireSession(); return } + setReady(true) + const identity = getSessionIdentity() + let navigating = false + const changed = (event: StorageEvent) => { + if (navigating) return + if (event.key !== 'authSession' && event.key !== null && !(identity.startsWith('legacy:') && event.key === 'refresh' && event.newValue === null)) return + if (getSessionIdentity() === identity && getRefreshToken()) return + navigating = true + setReady(false) + client.clear() + if (!getRefreshToken()) expireSession() + else window.location.replace('/shop') + } + window.addEventListener('storage', changed) + return () => window.removeEventListener('storage', changed) + }, [client]) + const query = useQuery({ + queryKey: ['cuser-info-x1'], + enabled: ready, + queryFn: async () => { + const result = await axiosInstance.get('cuser/info/') + if (result.data?.role && result.data.role !== 'workspace_customer') { + expireSession() + throw new Error('این حساب به پنل مشتریان دسترسی ندارد.') + } + return result + }, + retry: false, + staleTime: 30000, + refetchOnWindowFocus: true, + refetchInterval: 60000, + }) + if (!ready || query.isLoading) return

در حال بررسی نشست…

+ if (query.isError) return
+

{apiErrorMessage(query.error)}

+ +
+ return <>{children} +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 27bf579..747fa2d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,36 +2,88 @@ import axios from 'axios' -const splitTokens = false export type Tokens = { access: string; refresh: string } export type LoginOption = { uuid: string; cuser_role: { name_family: string; title: string; sys_ws: { name: string }; role_type: { role_name: string; role_name_en: string } } } +let sessionVersion = 0 +let sessionCleared = false +let clearedIdentity: string | null = null +let loginMobile = '' +let pendingLogout: Promise | null = null -function readToken(key: 'access' | 'refresh'): string | null { +function readStorage(key: string): string | null { if (typeof window === 'undefined') return null - const value = window.localStorage.getItem(key) - if (!value || value === 'undefined' || value === 'null') return null - return value + (splitTokens ? window.localStorage.getItem(key === 'access' ? '0a' : '0r') || '' : '') + try { return window.localStorage.getItem(key) } catch { return null } +} +function readToken(key: 'access' | 'refresh'): string | null { + if (sessionCleared) { + const current = readStorage('authSession') + if (!current || current === clearedIdentity) return null + // A new login in another tab must not be erased by this tab's old logout. + sessionCleared = false + } + const value = readStorage(key) + return value && !['undefined', 'null'].includes(value) ? value : null } export const getAccessToken = () => readToken('access') export const getRefreshToken = () => readToken('refresh') +export const getSessionIdentity = () => readStorage('authSession') || `legacy:${sessionVersion}` -export function saveTokens(data: Tokens): void { - if (!data || typeof data.access !== 'string' || !data.access || typeof data.refresh !== 'string' || !data.refresh) { - throw new Error('پاسخ ورود معتبر نیست. دوباره تلاش کنید.') - } - for (const key of ['access', 'refresh'] as const) { - window.localStorage.setItem(key, splitTokens ? data[key].slice(0, -2) : data[key]) - const suffix = key === 'access' ? '0a' : '0r' - if (splitTokens) window.localStorage.setItem(suffix, data[key].slice(-2)) - else window.localStorage.removeItem(suffix) +export function validTokens(data: unknown): data is Tokens { + const tokens = data as Tokens | null + return !!tokens && ['access', 'refresh'].every(key => { + const value = tokens[key as keyof Tokens] + return typeof value === 'string' && !!value.trim() && !['null', 'undefined'].includes(value) + }) +} + +export function saveTokens(data: Tokens, expectedRefresh?: string): void { + if (!validTokens(data)) throw new Error('پاسخ ورود معتبر نیست. دوباره تلاش کنید.') + if (expectedRefresh && getRefreshToken() !== expectedRefresh) throw new Error('نشست تغییر کرده است.') + const keys = ['access', 'refresh', 'authSession'] + const previous = keys.map(readStorage) + try { + window.localStorage.setItem('access', data.access) + window.localStorage.setItem('refresh', data.refresh) + if (!expectedRefresh) window.localStorage.setItem('authSession', `${Date.now()}-${Math.random()}`) + } catch { + // A partially stored pair cannot authenticate reliably. Restore the old + // session when possible and leave the current operation unsuccessful. + keys.forEach((key, index) => { + try { + if (previous[index] === null) window.localStorage.removeItem(key) + else window.localStorage.setItem(key, previous[index]!) + } catch { /* Storage can be disabled entirely by the browser. */ } + }) + throw new Error('ذخیرهٔ نشست در مرورگر ممکن نیست. اجازهٔ ذخیره‌سازی سایت را فعال کنید و دوباره تلاش کنید.') } + if (!expectedRefresh) sessionVersion++ + sessionCleared = false + for (const key of ['0a', '0r']) { try { window.localStorage.removeItem(key) } catch { /* Legacy keys only. */ } } document.cookie = 'isAuth=true;max-age=2592000;path=/;SameSite=Lax' } +export function rememberLoginMobile(mobile: string): void { + loginMobile = mobile + try { window.sessionStorage.setItem('loginMobile', mobile) } catch { /* The same-page form keeps its own mobile. */ } +} +export function readLoginMobile(): string { + if (loginMobile) return loginMobile + try { return window.sessionStorage.getItem('loginMobile') || '' } catch { return '' } +} +export function forgetLoginMobile(): void { + loginMobile = '' + try { window.sessionStorage.removeItem('loginMobile') } catch { /* Optional OTP navigation state. */ } +} + export function clearSession(): void { + clearedIdentity = readStorage('authSession') + sessionVersion++ + sessionCleared = true if (typeof window === 'undefined') return - for (const key of ['access', 'refresh', '0a', '0r', 'nameFamily']) window.localStorage.removeItem(key) - window.sessionStorage.removeItem('loginMobile') + for (const key of ['access', 'refresh', 'authSession', '0a', '0r', 'nameFamily']) { + try { window.localStorage.removeItem(key) } catch { /* Still invalidate this tab and the routing cookie. */ } + } + forgetLoginMobile() document.cookie = 'isAuth=;max-age=0;path=/;SameSite=Lax' } @@ -42,26 +94,31 @@ export function expireSession(): void { } } -export async function logout(): Promise { +async function revokeRefresh(refresh: string): Promise { + await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/logout/', { refresh }, { timeout: 5000 }) +} + +export function logout(): Promise { + if (pendingLogout) return pendingLogout const refresh = getRefreshToken() - clearSession() // Pending refresh responses cannot restore this session. - let destination = '/login' - try { - if (refresh) await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/logout/', { refresh }, { timeout: 5000 }) - } catch { - destination = '/login?reason=logout-unconfirmed' - } finally { - window.location.replace(destination) - } + clearSession() + pendingLogout = (async () => { + let destination = '/login' + try { if (refresh) await revokeRefresh(refresh) } + catch { destination = '/login?reason=logout-unconfirmed' } + finally { window.location.replace(destination) } + })().finally(() => { pendingLogout = null }) + return pendingLogout } function collectMessages(value: unknown): string[] { if (typeof value === 'string') return [value] if (Array.isArray(value)) return value.flatMap(collectMessages) - if (value && typeof value === 'object') return Object.values(value).flatMap(collectMessages) + if (value && typeof value === 'object') return Object.entries(value) + .filter(([key]) => !['code', 'retry_after', 'status', 'status_code'].includes(key)) + .flatMap(([, item]) => collectMessages(item)) return [] } - export function apiErrorMessage(error: unknown): string { if (axios.isAxiosError(error)) { const messages = collectMessages(error.response?.data) @@ -71,6 +128,14 @@ export function apiErrorMessage(error: unknown): string { } export async function obtainTokens(uuid: string): Promise { - const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/', { uuid_opt: uuid }, { timeout: 10000 }) - saveTokens(response.data) + const version = sessionVersion + const identity = getSessionIdentity() + const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/', { uuid_opt: uuid }, { timeout: 15000 }) + try { + if (version !== sessionVersion || identity !== getSessionIdentity() || pendingLogout) throw new Error('درخواست ورود لغو شده است. دوباره وارد شوید.') + saveTokens(response.data) + } catch (error) { + if (validTokens(response.data)) { try { await revokeRefresh(response.data.refresh) } catch { /* Keep the original, actionable error. */ } } + throw error + } } diff --git a/tests/auth-session.test.cjs b/tests/auth-session.test.cjs index 9434ab8..4fe7fe5 100644 --- a/tests/auth-session.test.cjs +++ b/tests/auth-session.test.cjs @@ -8,10 +8,12 @@ const realAxios = require('axios'); // Execute the actual TypeScript modules against Axios adapters and a browser stub. // No additional runtime/test dependencies, no network, no production API. -function harness() { - const local = new Map(); const session = new Map(); const redirects = []; +function harness(shared = {}) { + const local = shared.local || new Map(); const session = new Map(); const redirects = []; + const listeners = new Map(); const timers = new Map(); let clock = Date.now(); const storage = map => ({ getItem: key => map.get(key) ?? null, setItem: (key, value) => map.set(key, String(value)), removeItem: key => map.delete(key) }); - const window = { localStorage: storage(local), sessionStorage: storage(session), location: { pathname: '/shop', replace: value => redirects.push(value) } }; + const window = { localStorage: storage(local), sessionStorage: storage(session), location: { pathname: '/shop', search: '', replace: value => redirects.push(value) }, + addEventListener: (name, callback) => listeners.set(name, callback), removeEventListener: name => listeners.delete(name) }; const document = { cookie: '' }; const axios = realAxios.create(); axios.create = realAxios.create; axios.isAxiosError = realAxios.isAxiosError; @@ -19,11 +21,21 @@ function harness() { const hooks = []; let cursor = 0; const react = { ...require('react'), useState: initial => { const index = cursor++; - if (!(index in hooks)) hooks[index] = initial; + if (!(index in hooks)) hooks[index] = typeof initial === 'function' ? initial() : initial; return [hooks[index], value => { hooks[index] = typeof value === 'function' ? value(hooks[index]) : value; }]; - }, useEffect: effect => effect() }; + }, useRef: initial => { const index = cursor++; return hooks[index] ||= { current: initial }; }, + useEffect: (effect, deps) => { + const index = cursor++; const previous = hooks[index]; + if (!previous || !deps || deps.some((value, i) => value !== previous.deps[i])) { + previous?.cleanup?.(); hooks[index] = { deps, cleanup: effect() }; + } + } }; + const queryState = { isLoading: false, isError: false, error: null, refetch: async () => {} }; + const queryClient = { clears: 0, clear() { this.clears++; } }; + let queryOptions; + const navigator = { locks: Object.hasOwn(shared, 'locks') ? shared.locks : { request: (_name, callback) => callback() } }; const dispatches = []; - function render(component) { cursor = 0; return component(); } + function render(component, props = {}) { cursor = 0; return component(props); } function load(filename) { const full = path.resolve(filename); if (cache[full]) return cache[full].exports; @@ -33,18 +45,24 @@ function harness() { if (name === 'axios') return axios; if (name === 'react') return react; if (name === 'react/jsx-runtime') return require(name); + if (name === '@tanstack/react-query') return { useQuery: options => { queryOptions = options; return queryState; }, useQueryClient: () => queryClient }; if (name === 'next/navigation') return { useRouter: () => ({ push: value => redirects.push(value), replace: value => redirects.push(value) }) }; if (name === 'react-redux') return { useDispatch: () => action => dispatches.push(action), useSelector: select => select({ mobileopt: { mobile: 9123456789 } }) }; if (name.includes('mobileoptSlice')) return { addMobile: payload => ({ payload }) }; const base = path.join('src', name.slice(2)); return load(base + (fs.existsSync(base + '.ts') ? '.ts' : '.tsx')); }; - vm.runInNewContext('(function(require, module, exports){' + source + '\n})', { window, document, process, Error, Promise, URLSearchParams, console })(requireStub, module, module.exports); + vm.runInNewContext('(function(require, module, exports){' + source + '\n})', { window, document, navigator, process, Error, Promise, URLSearchParams, console, + Date: class extends Date { static now() { return clock; } }, + setInterval: callback => { const id = timers.size + 1; timers.set(id, callback); return id; }, clearInterval: id => timers.delete(id), + setTimeout, + })(requireStub, module, module.exports); return module.exports; } const auth = load('src/lib/auth.ts'); const instance = load('src/axios/axiosInstance.ts').default; - return { auth, instance, axios, local, session, redirects, document, load, render, dispatches }; + return { auth, instance, axios, local, session, redirects, document, window, load, render, dispatches, listeners, queryState, queryClient, + get queryOptions() { return queryOptions; }, advance: ms => { clock += ms; timers.forEach(callback => callback()); } }; } function failure(config, status, data = { detail: 'Error' }) { return new realAxios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', config, null, { data, status, statusText: 'Failed', headers: {}, config }); @@ -153,7 +171,8 @@ function textContent(tree) { return tree?.props ? textContent(tree.props.children) : ''; } test('login page renders API errors and re-enables submit', async () => { - const h = harness(); const component = h.load('src/app/(login_register)/login/page.tsx').default; + const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default; + elements(h.render(component), item => item.type === 'input')[0].props.onChange({ target: { value: '09123456789' } }); h.axios.defaults.adapter = config => Promise.reject(failure(config, 503, { detail: 'ارسال پیامک انجام نشد' })); const form = elements(h.render(component), item => item.type === 'form')[0]; await form.props.onSubmit({ preventDefault() {} }); @@ -165,20 +184,22 @@ function textContent(tree) { }); test('customer login does not navigate when SMS is rejected or account is absent', async () => { - const h = harness(); const component = h.load('src/app/(login_register)/login/page.tsx').default; + const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default; + elements(h.render(component), item => item.type === 'input')[0].props.onChange({ target: { value: '09123456789' } }); h.axios.defaults.adapter = config => Promise.reject(failure(config, 400, { mobile: ['کاربر ثبت نشده است'] })); await elements(h.render(component), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }); assert.ok(textContent(h.render(component)).includes('کاربر ثبت نشده است')); assert.equal(h.dispatches.length, 0); assert.equal(h.redirects.length, 0); }); test('OTP page retains code input after wrong code', async () => { - const h = harness(); const component = h.load('src/app/(login_register)/login/verify/page.tsx').default; - h.render(component); - let tree = h.render(component); + const h = harness(); const component = h.load('src/components/auth/customer-login-form.tsx').default; + h.auth.rememberLoginMobile('9123456789'); + const render = () => h.render(component, { initialStep: 'verify' }); + let tree = render(); elements(tree, item => item.type === 'input')[0].props.onChange({ target: { value: '۱۲۳۴۵۶' } }); h.axios.defaults.adapter = config => Promise.reject(failure(config, 400, { opt: ['کد تأیید اشتباه است'] })); - await elements(h.render(component), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }); - tree = h.render(component); + await elements(render(), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }); + tree = render(); assert.ok(textContent(tree).includes('کد تأیید اشتباه است')); assert.equal(elements(tree, item => item.type === 'input')[0].props.value, '۱۲۳۴۵۶'); assert.equal(h.redirects.length, 0); @@ -194,3 +215,250 @@ function textContent(tree) { await assert.rejects(h.instance.get('/cuser/info/')); assert.equal(h.auth.getAccessToken(), 'access.other-tab'); assert.equal(h.redirects.length, 0); }); + +function customerForm(h, props = {}) { + const component = h.load('src/components/auth/customer-login-form.tsx').default; + return { render: () => h.render(component, props), submit: () => elements(h.render(component, props), item => item.type === 'form')[0].props.onSubmit({ preventDefault() {} }), + input: value => elements(h.render(component, props), item => item.type === 'input')[0].props.onChange({ target: { value } }) }; +} +const customerOption = name => ({ uuid: name, cuser_role: { sys_ws: { name }, role_type: { role_name_en: 'workspace_customer' } } }); +const settle = () => new Promise(resolve => setImmediate(resolve)); + + test('SMS success displays OTP in the same page even when sessionStorage is blocked', async () => { + const h = harness(); const form = customerForm(h); + h.window.sessionStorage.setItem = () => { throw new Error('Storage denied'); }; + h.axios.defaults.adapter = config => Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60 } })); + form.input('۰۹۱۲۳۴۵۶۷۸۹'); + await form.submit(); + assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length); + assert.ok(textContent(form.render()).includes('09123456789')); + assert.equal(h.redirects.length, 0); + assert.equal(h.auth.readLoginMobile(), '9123456789'); + }); + test('same-render double login submits only one SMS request', async () => { + const h = harness(); const form = customerForm(h); let resolve; let requests = 0; + form.input('09123456789'); + h.axios.defaults.adapter = config => { requests++; return new Promise(done => { resolve = () => done(response(config, { result: { mobile: 9123456789 } })); }); }; + const handler = elements(form.render(), item => item.type === 'form')[0].props.onSubmit; + const first = handler({ preventDefault() {} }); const second = handler({ preventDefault() {} }); + await settle(); assert.equal(requests, 1); + assert.ok(elements(form.render(), item => item.type === 'button').every(item => item.props.disabled)); + resolve(); await Promise.all([first, second]); + }); + test('absent account and disabled panel messages render without navigating or opening OTP', async () => { + for (const [status, message] of [[400, 'کاربری با این شماره ثبت نشده است'], [403, 'دسترسی شما به پنل مشتریان فعال نیست']]) { + const h = harness(); const form = customerForm(h); form.input('09123456789'); + h.axios.defaults.adapter = config => Promise.reject(failure(config, status, { detail: message })); + await form.submit(); + assert.ok(textContent(form.render()).includes(message)); + assert.equal(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length, 0); + assert.equal(h.redirects.length, 0); + } + }); + test('lost SMS response permits entering the code that was actually received', async () => { + const h = harness(); const form = customerForm(h); form.input('09123456789'); + h.axios.defaults.adapter = config => Promise.reject(new realAxios.AxiosError('Timeout', 'ECONNABORTED', config)); + await form.submit(); + const recovery = elements(form.render(), item => item.type === 'button' && textContent(item).includes('دریافت کرده'))[0]; + assert.ok(recovery); recovery.props.onClick(); + assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length); + }); + test('429 displays a countdown and can recover the previously sent code', async () => { + const h = harness(); const form = customerForm(h); form.input('09123456789'); + h.axios.defaults.adapter = config => Promise.reject(failure(config, 429, { detail: 'کد قبلی را وارد کنید', retry_after: 30 })); + await form.submit(); + const tree = form.render(); + assert.ok(textContent(tree).includes('30 ثانیه')); + assert.equal(elements(tree, item => item.type === 'button' && item.props.type === 'submit')[0].props.disabled, true); + h.advance(31000); + assert.equal(elements(form.render(), item => item.type === 'button' && item.props.type === 'submit')[0].props.disabled, false); + }); + test('resend remains disabled during its cooldown and sends once after it ends', async () => { + const h = harness(); const form = customerForm(h); form.input('09123456789'); let requests = 0; + h.axios.defaults.adapter = config => { requests++; return Promise.resolve(response(config, { result: { mobile: 9123456789, retry_after: 60 } })); }; + await form.submit(); + let resend = elements(form.render(), item => item.type === 'button' && textContent(item) === 'دریافت دوباره کد')[0]; + assert.equal(resend.props.disabled, true); await resend.props.onClick(); assert.equal(requests, 1); + h.advance(61000); + resend = elements(form.render(), item => item.type === 'button' && textContent(item) === 'دریافت دوباره کد')[0]; + assert.equal(resend.props.disabled, false); await resend.props.onClick(); assert.equal(requests, 2); + }); + test('same-render double OTP submit verifies and exchanges tokens only once', async () => { + const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('۱۲۳۴۵۶'); + let verifyRequests = 0; let tokenRequests = 0; let release; + h.axios.defaults.adapter = config => { + if (config.url.includes('/verify/')) { verifyRequests++; return new Promise(done => { release = () => done(response(config, [customerOption('Workspace')])); }); } + tokenRequests++; return Promise.resolve(response(config, { access: 'access.logged-in', refresh: 'refresh.logged-in' })); + }; + const handler = elements(form.render(), item => item.type === 'form')[0].props.onSubmit; + const first = handler({ preventDefault() {} }); const second = handler({ preventDefault() {} }); + await settle(); assert.equal(verifyRequests, 1); release(); await Promise.all([first, second]); + assert.equal(tokenRequests, 1); assert.equal(h.auth.getAccessToken(), 'access.logged-in'); + assert.deepEqual(h.redirects, ['/shop']); + assert.ok(elements(form.render(), item => item.type === 'button').every(item => item.props.disabled)); + }); + test('token exchange failure preserves verified choices for retry without consuming OTP twice', async () => { + const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('123456'); + let verifications = 0; let exchanges = 0; + h.axios.defaults.adapter = config => { + if (config.url.includes('/verify/')) { verifications++; return Promise.resolve(response(config, [customerOption('Workspace')])); } + exchanges++; + return exchanges === 1 ? Promise.reject(failure(config, 503, { detail: 'ارتباط قطع شد' })) : Promise.resolve(response(config, { access: 'access.ok', refresh: 'refresh.ok' })); + }; + await form.submit(); + assert.ok(textContent(form.render()).includes('ارتباط قطع شد')); + const choice = elements(form.render(), item => item.type === 'button' && textContent(item) === 'Workspace')[0]; + await choice.props.onClick(); + assert.equal(verifications, 1); assert.equal(exchanges, 2); assert.deepEqual(h.redirects, ['/shop']); + }); + test('multiple workspace choice uses a synchronous lock and ignores operator roles', async () => { + const h = harness(); h.auth.rememberLoginMobile('9123456789'); const form = customerForm(h, { initialStep: 'verify' }); form.input('123456'); let exchanges = 0; let release; + const operator = customerOption('Operator'); operator.cuser_role.role_type.role_name_en = 'workspace_operator'; + h.axios.defaults.adapter = config => config.url.includes('/verify/') ? Promise.resolve(response(config, [customerOption('One'), customerOption('Two'), operator])) : new Promise(done => { exchanges++; release = () => done(response(config, { access: 'access.ok', refresh: 'refresh.ok' })); }); + await form.submit(); assert.equal(exchanges, 0); assert.ok(!textContent(form.render()).includes('Operator')); + const choice = elements(form.render(), item => item.type === 'button' && textContent(item) === 'One')[0]; + const first = choice.props.onClick(); const second = choice.props.onClick(); await settle(); assert.equal(exchanges, 1); + release(); await Promise.all([first, second]); + }); + test('direct verification URL without navigation state presents a usable mobile form', () => { + const h = harness(); const form = customerForm(h, { initialStep: 'verify' }); + assert.ok(elements(form.render(), item => item.props?.id === 'mobile').length); + assert.equal(h.redirects.length, 0); + }); + test('OTP verified route uses remembered state without Redux or working sessionStorage', () => { + const h = harness(); h.window.sessionStorage.setItem = () => { throw new Error('denied'); }; h.auth.rememberLoginMobile('9123456789'); + const form = customerForm(h, { initialStep: 'verify' }); + assert.ok(elements(form.render(), item => item.props?.autoComplete === 'one-time-code').length); + }); + test('logout double invocation revokes once and redirects once', async () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; let release; + h.axios.defaults.adapter = config => { calls++; return new Promise(done => { release = () => done(response(config, null, 204)); }); }; + const first = h.auth.logout(); const second = h.auth.logout(); assert.equal(first, second); + await settle(); assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), null); + release(); await Promise.all([first, second]); assert.deepEqual(h.redirects, ['/login']); + }); + test('late login token response after logout is revoked and cannot restore the session', async () => { + const h = harness(); let release; let revoked = 0; + h.axios.defaults.adapter = config => config.url.includes('/auth/token/') ? new Promise(done => { release = () => done(response(config, { access: 'access.late', refresh: 'refresh.late' })); }) : (revoked++, Promise.resolve(response(config, null, 204))); + const pending = h.auth.obtainTokens('login-option'); await settle(); await h.auth.logout(); release(); + await assert.rejects(pending); assert.equal(h.auth.getAccessToken(), null); assert.equal(revoked, 1); + }); + test('storage failure has a useful message and partially written tokens are rolled back', () => { + const h = harness(); const original = h.window.localStorage.setItem; + h.window.localStorage.setItem = (key, value) => { if (key === 'refresh') throw new Error('quota'); original(key, value); }; + assert.throws(() => h.auth.saveTokens({ access: 'access.new', refresh: 'refresh.new' }), /ذخیره/); + assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null); + }); + test('clearing session still invalidates in-memory reads when storage removal fails', () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); + h.window.localStorage.removeItem = () => { throw new Error('denied'); }; + h.auth.clearSession(); assert.equal(h.auth.getAccessToken(), null); assert.equal(h.auth.getRefreshToken(), null); + assert.match(h.document.cookie, /max-age=0/); + }); + test('old account request is never retried under a newly logged-in identity', async () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; + h.instance.defaults.adapter = config => { calls++; h.auth.saveTokens({ access: 'access.other-person', refresh: 'refresh.other-person' }); return Promise.reject(failure(config, 401)); }; + await assert.rejects(h.instance.post('/shop/cart/', { product: 10 })); + assert.equal(calls, 1); assert.equal(h.auth.getAccessToken(), 'access.other-person'); assert.equal(h.redirects.length, 0); + }); + test('simultaneous refreshes in separate tabs serialize through Web Locks', async () => { + const local = new Map(); let queue = Promise.resolve(); let lockCalls = 0; let refreshCalls = 0; + const locks = { request(name, callback) { assert.equal(name, 'crm-customer-jwt-refresh'); lockCalls++; const result = queue.then(callback); queue = result.catch(() => {}); return result; } }; + const first = harness({ local, locks }); const second = harness({ local, locks }); first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); + for (const h of [first, second]) { + h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {})); + h.axios.defaults.adapter = async config => { refreshCalls++; await settle(); return response(config, { access: 'access.rotated', refresh: 'refresh.rotated' }); }; + } + await Promise.all([first.instance.get('/first/'), second.instance.get('/second/')]); + assert.equal(lockCalls, 2); assert.equal(refreshCalls, 1); assert.equal(second.auth.getAccessToken(), 'access.rotated'); + }); + test('a new identity can refresh while a previous identity refresh is still pending', async () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let releaseOld; + h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.current' ? Promise.resolve(response(config, {})) : Promise.reject(failure(config, 401)); + h.axios.defaults.adapter = config => JSON.parse(config.data).refresh === 'refresh.old' ? new Promise(done => { releaseOld = () => done(response(config, { access: 'access.stale', refresh: 'refresh.stale' })); }) : Promise.resolve(response(config, { access: 'access.current', refresh: 'refresh.current' })); + const old = h.instance.get('/previous-person/'); await settle(); h.auth.saveTokens({ access: 'access.new-person', refresh: 'refresh.new-person' }); + await h.instance.get('/current-person/'); releaseOld(); await assert.rejects(old); + assert.equal(h.auth.getAccessToken(), 'access.current'); assert.equal(h.redirects.length, 0); + }); + test('protected UI with a stale routing cookie and no real session never displays children', () => { + const h = harness(); h.document.cookie = 'isAuth=true'; const guard = h.load('src/components/auth/session-guard.tsx').default; + const tree = h.render(guard, { children: 'Private customer data' }); + assert.ok(!textContent(tree).includes('Private customer data')); assert.equal(h.queryOptions.enabled, false); + assert.deepEqual(h.redirects, ['/login?reason=session']); + }); + test('protected session checks revalidate on focus and periodically, preserving network failure for retry', async () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); const guard = h.load('src/components/auth/session-guard.tsx').default; + h.render(guard, { children: 'Private data' }); h.render(guard, { children: 'Private data' }); + assert.equal(h.queryOptions.enabled, true); assert.equal(h.queryOptions.retry, false); assert.equal(h.queryOptions.refetchOnWindowFocus, true); assert.equal(h.queryOptions.refetchInterval, 60000); + h.queryState.isError = true; h.queryState.error = failure({}, 503, { detail: 'ارتباط موقتاً قطع است' }); + const tree = h.render(guard, { children: 'Private data' }); + assert.ok(textContent(tree).includes('ارتباط موقتاً قطع است')); assert.ok(!textContent(tree).includes('Private data')); + assert.equal(h.auth.getRefreshToken(), 'refresh.old'); assert.equal(h.redirects.length, 0); + }); + test('logout or identity change in another tab clears protected cached data', () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); const guard = h.load('src/components/auth/session-guard.tsx').default; + h.render(guard, { children: 'Private data' }); h.render(guard, { children: 'Private data' }); + h.local.delete('access'); h.local.delete('refresh'); h.local.delete('authSession'); + h.listeners.get('storage')({ key: 'authSession', newValue: null }); + assert.equal(h.queryClient.clears, 1); assert.deepEqual(h.redirects, ['/login?reason=session']); + assert.ok(!textContent(h.render(guard, { children: 'Private data' })).includes('Private data')); + }); + + test('invalid stored OTP mobile falls back to the mobile form', () => { + const h = harness(); h.session.set('loginMobile', 'undefined'); const form = customerForm(h, { initialStep: 'verify' }); + assert.ok(elements(form.render(), item => item.props?.id === 'mobile').length); + }); + test('new login in another tab survives this tab previously clearing its session', () => { + const local = new Map(); const first = harness({ local }); const second = harness({ local }); + first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); first.auth.clearSession(); + second.auth.saveTokens({ access: 'access.new-person', refresh: 'refresh.new-person' }); + assert.equal(first.auth.getAccessToken(), 'access.new-person'); assert.equal(first.auth.getRefreshToken(), 'refresh.new-person'); + }); + test('late successful response from a previous identity is rejected instead of displaying old data', async () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); + h.instance.defaults.adapter = config => { h.auth.saveTokens({ access: 'access.other-person', refresh: 'refresh.other-person' }); return Promise.resolve(response(config, { private: 'previous person' })); }; + await assert.rejects(h.instance.get('/orders/'), /نشست/); + assert.equal(h.auth.getAccessToken(), 'access.other-person'); + }); + test('old request sees another-tab rotation and retries without issuing another refresh', async () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let first = true; let refreshes = 0; + h.axios.defaults.adapter = config => { refreshes++; return Promise.resolve(response(config, {})); }; + h.instance.defaults.adapter = config => { + if (first) { first = false; h.auth.saveTokens({ access: 'access.rotated', refresh: 'refresh.rotated' }, 'refresh.old'); return Promise.reject(failure(config, 401)); } + assert.equal(config.headers.Authorization, 'Bearer access.rotated'); return Promise.resolve(response(config, {})); + }; + await h.instance.get('/orders/'); assert.equal(refreshes, 0); + }); + test('navigation wrappers both use the shared form and preserve the verification route', () => { + const h = harness(); const login = h.load('src/app/(login_register)/login/page.tsx').default; + const verify = h.load('src/app/(login_register)/login/verify/page.tsx').default; + const form = h.load('src/components/auth/customer-login-form.tsx').default; + assert.equal(h.render(login).type, form); assert.equal(h.render(verify).type, form); + assert.equal(h.render(verify).props.initialStep, 'verify'); + }); + test('logout button handles same-render repeated clicks and becomes disabled', async () => { + const h = harness(); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let calls = 0; + h.axios.defaults.adapter = config => { calls++; return Promise.resolve(response(config, null, 204)); }; + const component = h.load('src/app/components/layout/customer-info.tsx').default; + const button = elements(h.render(component), item => item.type === 'button')[0]; + button.props.onClick(); button.props.onClick(); await settle(); + assert.equal(calls, 1); assert.equal(elements(h.render(component), item => item.type === 'button')[0].props.disabled, true); + }); + + test('tabs without Web Locks serialize rotating refresh through an expiring storage lease', async () => { + const local = new Map(); const first = harness({ local, locks: null }); const second = harness({ local, locks: null }); + first.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); let requests = 0; + for (const h of [first, second]) { + h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {})); + h.axios.defaults.adapter = async config => { requests++; await settle(); return response(config, { access: 'access.rotated', refresh: 'refresh.rotated' }); }; + } + await Promise.all([first.instance.get('/first/'), second.instance.get('/second/')]); + assert.equal(requests, 1); assert.equal(local.has('customerRefreshLease'), false); + }); + test('an abandoned expired refresh lease cannot block a later refresh', async () => { + const h = harness({ locks: null }); h.auth.saveTokens({ access: 'access.old', refresh: 'refresh.old' }); + h.local.set('customerRefreshLease', JSON.stringify({ owner: 'closed-tab', identity: h.auth.getSessionIdentity(), expires: 0 })); + h.instance.defaults.adapter = config => config.headers.Authorization === 'Bearer access.old' ? Promise.reject(failure(config, 401)) : Promise.resolve(response(config, {})); + h.axios.defaults.adapter = config => Promise.resolve(response(config, { access: 'access.new', refresh: 'refresh.new' })); + await h.instance.get('/orders/'); assert.equal(h.auth.getAccessToken(), 'access.new'); assert.equal(h.local.has('customerRefreshLease'), false); + });