fix login bug

This commit is contained in:
javad committed 2026-10-09 10:06:34 +03:30
1 parent 4d350c0278
commit 880ac58042
9 files changed
+709 -208

No files matched your search

+2 -41
View File
@@ -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 <div className="mx-auto w-full sm:w-[30rem] mt-[-10rem]" dir="rtl">
<form onSubmit={login} className="flex flex-col gap-y-3 px-8">
<h1 className="text-center border-b-2 border-black pb-4 text-2xl">ورود به سامانه مشتریان</h1>
<label htmlFor="mobile">موبایل</label>
<input id="mobile" type="tel" inputMode="tel" autoComplete="tel" required value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره موبایل را وارد کنید" className="w-full h-[45px] p-5 rounded-md shadow-sm" />
{error && <p role="alert" className="text-red-700 bg-red-100 rounded p-2">{error}</p>}
<button disabled={busy} className="bg-slate-900 text-white rounded p-2 disabled:opacity-50">{busy ? 'در حال ارسال کد…' : 'دریافت کد ورود'}</button>
</form>
</div>
return <CustomerLoginForm />
}
+2 -57
View File
@@ -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<LoginOption[]>([])
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 <div className="mx-auto w-full sm:w-[30rem] mt-[-10rem]" dir="rtl">
<form onSubmit={verify} className="flex flex-col gap-y-3 px-8">
<h1 className="text-center border-b-2 border-black pb-4 text-2xl">ورود به سامانه مشتریان</h1>
<p className="text-center">کد تایید پیامک‌شده را وارد کنید</p>
{!options.length && <><input aria-label="کد تایید" autoComplete="one-time-code" inputMode="numeric" value={code} onChange={e => setCode(e.target.value)} maxLength={6} required className="w-full h-[45px] p-5 rounded-md shadow-sm" /><button disabled={busy || !mobile} className="bg-slate-900 text-white rounded p-2">{busy ? 'در حال بررسی…' : 'ورود به سامانه'}</button></>}
{options.length > 0 && <div className="flex flex-col gap-2"><p>پنل موردنظر را انتخاب کنید:</p>{options.map(option => <button type="button" key={option.uuid} disabled={busy} onClick={() => choose(option)} className="border rounded p-2">{option.cuser_role.sys_ws.name}</button>)}</div>}
{error && <p role="alert" className="text-red-700 bg-red-100 rounded p-2">{error}</p>}
<button type="button" onClick={() => router.push('/login')} className="underline">تغییر شماره یا دریافت دوباره کد</button>
</form>
</div>
return <CustomerLoginForm initialStep="verify" />
}
+13 -9
View File
@@ -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<string | null>(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 (
<div className="flex flex-row gap-x-2">
<div className="disable-select">{nameFamily}</div>
<div onClick={onLogout} className="disable-select bg-slate-950 rounded-md pr-1 pl-1 cursor-pointer">خروج</div>
<div className="disable-select">{query.data?.data.name_family}</div>
<button type="button" disabled={leaving} onClick={onLogout} className="disable-select bg-slate-950 rounded-md pr-1 pl-1 cursor-pointer disabled:opacity-50">{leaving ? 'در حال خروج…' : 'خروج'}</button>
</div>
)
}
export default CustomerInfo
export default CustomerInfo
+18 -29
View File
@@ -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 (
<div className={`basis-full md:mr-[8.9rem] ${pathname.startsWith('/shop') ? 'lg:ml-[17rem]': ''} `}>
return <div className={`basis-full md:mr-[8.9rem] ${pathname.startsWith('/shop') ? 'lg:ml-[17rem]' : ''}`}>
<ReduxProvider>
<ReactQueryProvider>
<div className='w-full h-[2.5rem] flex flex-row bg-gray-800 p-2 text-white'>
<HamberMenu />
<div className='pr-4'>
<CustomerInfo />
</div>
</div>
{children}
</ReactQueryProvider>
<ReactQueryProvider>
<SessionGuard>
<div className="w-full h-[2.5rem] flex flex-row bg-gray-800 p-2 text-white">
<HamberMenu />
<div className="pr-4"><CustomerInfo /></div>
</div>
{children}
</SessionGuard>
</ReactQueryProvider>
</ReduxProvider>
</div>
)
</div>
}
export default MainLayoutUtil
export default MainLayoutUtil
+86 -27
View File
@@ -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<string> | null = null
let pendingRefresh: { identity: string; promise: Promise<string> } | 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<string>): Promise<string> {
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<string> {
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<string> {
const originalRefresh = getRefreshToken()
async function rotate(): Promise<string> {
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
+159
View File
@@ -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<Step>(() => initialStep === 'verify' && normalizePhone(readLoginMobile()) ? 'verify' : 'mobile')
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [options, setOptions] = useState<LoginOption[]>([])
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<void>) {
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 <div className="mx-auto w-full sm:w-[30rem]" dir="rtl">
<form onSubmit={step === 'mobile' ? sendCode : verify} className="flex flex-col gap-y-3 px-8" aria-busy={busy}>
<h1 className="text-center border-b-2 border-black pb-4 text-2xl">ورود به سامانه مشتریان</h1>
{step === 'mobile' ? <>
<label htmlFor="mobile">موبایل</label>
<input id="mobile" type="tel" inputMode="tel" autoComplete="tel" required disabled={busy} value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره موبایل را وارد کنید" className="w-full h-[45px] p-5 rounded-md shadow-sm" />
<button type="submit" disabled={busy || remaining > 0} className="bg-slate-900 text-white rounded p-2 disabled:opacity-50">{busy ? 'در حال ارسال کد…' : 'دریافت کد ورود'}</button>
{recoveryMobile && <button type="button" disabled={busy} className="underline" onClick={() => {
if (locked.current) return
rememberLoginMobile(recoveryMobile)
setMobile(recoveryMobile)
setStep('verify')
setCode('')
setOptions([])
setError('')
}}>کد پیامک را دریافت کرده‌ام</button>}
</> : <>
<p>کد شش‌رقمی پیامک‌شده به شمارهٔ <b dir="ltr">0{mobile}</b> را وارد کنید.</p>
{!options.length ? <>
<label htmlFor="otp">کد تأیید</label>
<input id="otp" aria-label="کد تایید" autoComplete="one-time-code" inputMode="numeric" disabled={busy} value={code} onChange={e => setCode(e.target.value)} maxLength={6} required className="w-full h-[45px] p-5 rounded-md shadow-sm" />
<button type="submit" disabled={busy} className="bg-slate-900 text-white rounded p-2 disabled:opacity-50">{busy ? 'در حال بررسی…' : 'ورود به سامانه'}</button>
</> : <div className="flex flex-col gap-2"><p>پنل موردنظر را انتخاب کنید:</p>{options.map(option => <button type="button" key={option.uuid} disabled={busy} onClick={() => run(() => finishLogin(option))} className="border rounded p-2 disabled:opacity-50">{option.cuser_role.sys_ws.name}</button>)}</div>}
<button type="button" disabled={busy || remaining > 0} onClick={() => sendCode()} className="underline disabled:opacity-50">دریافت دوباره کد</button>
<button type="button" disabled={busy} onClick={changeMobile} className="underline disabled:opacity-50">تغییر شماره</button>
</>}
{remaining > 0 && <p role="status">ارسال دوبارهٔ کد پس از {remaining} ثانیه امکان‌پذیر است.</p>}
{error && <p role="alert" className="text-red-700 bg-red-100 rounded p-2">{error}</p>}
</form>
</div>
}
+51
View File
@@ -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 <p role="status" className="p-6">در حال بررسی نشست…</p>
if (query.isError) return <div className="p-6" dir="rtl">
<p role="alert">{apiErrorMessage(query.error)}</p>
<button type="button" onClick={() => query.refetch()} className="underline">تلاش دوباره</button>
</div>
return <>{children}</>
}
+95 -30
View File
@@ -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<void> | 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<void> {
async function revokeRefresh(refresh: string): Promise<void> {
await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/logout/', { refresh }, { timeout: 5000 })
}
export function logout(): Promise<void> {
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<void> {
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
}
}
+283 -15
View File
@@ -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);
});