Added functionality to synchronize access token refresh across multiple tabs using the `navigator.locks` API. This prevents race conditions when refreshing tokens, ensuring that a newer token published by another tab is reused instead of rotating the refresh token unnecessarily. Introduced a new method `refreshAccessTokenAcrossTabs` and updated the `getOrRefreshAccessToken` function to utilize this new method. Enhanced error handling for 401 responses to retry with the latest stored token when applicable.
601 lines
19 KiB
TypeScript
601 lines
19 KiB
TypeScript
import axios, { AxiosError, AxiosHeaders, type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||
import { jwtDecode } from 'jwt-decode'
|
||
import Cookies from 'js-cookie'
|
||
|
||
import type { UserStorageData, DecodedToken } from '@/types'
|
||
import { buildGuestLoginRedirect, CONSUMER_SESSION_EXPIRED_EVENT } from '@/lib/authRouting'
|
||
import { reportBrowserEvent } from '@/lib/observability/client'
|
||
import { parseRefreshExpiry } from '@/lib/refreshTokenExpiry'
|
||
import { localizeApiErrorPayload } from '@/services/apiErrorLocalization'
|
||
import { setAccountSuspended } from '@/lib/accountSuspension'
|
||
import { applyFormDataRequestDefaults } from '@/lib/formDataRequest'
|
||
import { expireRefreshSessionOnThisOrigin } from '@/lib/expireRefreshSession'
|
||
|
||
const resolveApiBaseUrl = (raw?: string): string | undefined => {
|
||
if (!raw) return undefined
|
||
|
||
const trimmed = raw.replace(/\/+$/, '')
|
||
|
||
if (trimmed.endsWith('/api/v1')) return `${trimmed}/`
|
||
|
||
return `${trimmed}/api/v1/`
|
||
}
|
||
|
||
const AUTH_CONFIG = {
|
||
baseURL: resolveApiBaseUrl(process.env.NEXT_PUBLIC_API_URL),
|
||
cookieName: 'accessToken',
|
||
/** Legacy JS-readable cookie from the abandoned body-token model — remove only. */
|
||
legacyRefreshCookieName: 'refreshToken',
|
||
localStorageKey: 'user',
|
||
refreshPath: 'auth/refresh',
|
||
}
|
||
|
||
const TOKEN_MESSAGES = {
|
||
accessInvalid: new Set(['ACCESS_TOKEN_INVALID', 'ACCESS_TOKEN_EXPIRED', 'UNAUTHORIZED']),
|
||
}
|
||
|
||
type RequestConfigWithRetry = InternalAxiosRequestConfig & {
|
||
_retry?: boolean
|
||
/** One retry using a newer token published while this request was in flight. */
|
||
_authTokenSyncRetry?: boolean
|
||
}
|
||
|
||
interface RefreshApiResponse {
|
||
success: boolean
|
||
data?: {
|
||
accessToken: string
|
||
sessionId?: string
|
||
expiresAt?: string
|
||
}
|
||
body?: {
|
||
accessToken: string
|
||
sessionId?: string
|
||
expiresAt?: string
|
||
}
|
||
message?: string
|
||
code?: string
|
||
}
|
||
|
||
const isClient = typeof window !== 'undefined'
|
||
const AUTH_REFRESH_LOCK_NAME = 'ghabilee:auth-refresh'
|
||
let refreshPromise: Promise<string | null> | null = null
|
||
let sessionExpiryInFlight = false
|
||
|
||
/**
|
||
* صفر کردن state ماژولسطح axios (مثلاً بین تستها، یا بعد از ورود مجدد نرم
|
||
* بدون hard navigation که ماژول را از نو لود کند).
|
||
*/
|
||
export const resetAxiosAuthModuleState = (): void => {
|
||
refreshPromise = null
|
||
sessionExpiryInFlight = false
|
||
}
|
||
|
||
const isTokenExpired = (expireTime: number): boolean => Date.now() >= expireTime
|
||
|
||
const redirectToLogin = (): void => {
|
||
if (!isClient) return
|
||
|
||
// Admin session expiry → `/auth`. Consumer/host → home + AuthGate modal.
|
||
const target = buildGuestLoginRedirect(window.location.origin, window.location.pathname, window.location.search)
|
||
const targetUrl = `${target.pathname}${target.search}`
|
||
|
||
if (target.pathname === '/auth' || target.pathname.startsWith('/auth/')) {
|
||
window.location.replace(targetUrl)
|
||
|
||
return
|
||
}
|
||
|
||
window.dispatchEvent(new CustomEvent(CONSUMER_SESSION_EXPIRED_EVENT, { detail: { targetUrl } }))
|
||
}
|
||
|
||
const clearAuthData = (): void => {
|
||
if (!isClient) return
|
||
localStorage.removeItem(AUTH_CONFIG.localStorageKey)
|
||
Cookies.remove(AUTH_CONFIG.cookieName)
|
||
Cookies.remove(AUTH_CONFIG.legacyRefreshCookieName)
|
||
Cookies.remove('userStatus')
|
||
Cookies.remove('userRole')
|
||
}
|
||
|
||
const handleSessionExpired = async (): Promise<never> => {
|
||
if (sessionExpiryInFlight) {
|
||
throw new AxiosError('نشست منقضی شده است')
|
||
}
|
||
|
||
sessionExpiryInFlight = true
|
||
try {
|
||
clearAuthData()
|
||
window.dispatchEvent(new Event('userChanged'))
|
||
await expireRefreshSessionOnThisOrigin()
|
||
redirectToLogin()
|
||
throw new AxiosError('نشست منقضی شده است')
|
||
} finally {
|
||
// بعد از اتمام این دور، ورود مجدد نرم (AuthGate بدون reload) باید بتواند
|
||
// دوباره مسیر expiry را طی کند؛ تستها هم به همین دلیل flaky بودند.
|
||
sessionExpiryInFlight = false
|
||
}
|
||
}
|
||
|
||
const tryRefreshAccessToken = async (): Promise<string | null> => {
|
||
try {
|
||
return await getOrRefreshAccessToken()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const createStorageData = (
|
||
accessToken: string,
|
||
decodedAccess: DecodedToken,
|
||
refreshExpireTime: number,
|
||
sessionId?: string
|
||
): UserStorageData => ({
|
||
accessToken,
|
||
userId: decodedAccess.sub,
|
||
role: decodedAccess.role,
|
||
sessionId,
|
||
AccessTokenExpireTime: decodedAccess.exp * 1000,
|
||
refreshTokenExpireTime: refreshExpireTime,
|
||
})
|
||
|
||
const persistUserStorage = (next: UserStorageData & Record<string, unknown>): void => {
|
||
const { refreshToken: _legacyRefreshToken, ...sanitized } = next as UserStorageData & {
|
||
refreshToken?: unknown
|
||
}
|
||
|
||
localStorage.setItem(AUTH_CONFIG.localStorageKey, JSON.stringify(sanitized))
|
||
}
|
||
|
||
const setAccessCookie = (token: string, cookieExpiryTime: number): void => {
|
||
const expireInSeconds = Math.floor((cookieExpiryTime - Date.now()) / 1000)
|
||
|
||
if (expireInSeconds <= 0) return
|
||
Cookies.set(AUTH_CONFIG.cookieName, token, { expires: expireInSeconds / 86400, sameSite: 'lax' })
|
||
}
|
||
|
||
const getStoredUser = (): (UserStorageData & Record<string, unknown>) | null => {
|
||
if (!isClient) return null
|
||
const raw = localStorage.getItem(AUTH_CONFIG.localStorageKey)
|
||
|
||
if (!raw) return null
|
||
|
||
try {
|
||
return JSON.parse(raw) as UserStorageData & Record<string, unknown>
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Refresh sends the Nest httpOnly cookie via `withCredentials` — JS cannot
|
||
* read that cookie. Guests (no stored session and no access cookie) must
|
||
* never hit `auth/refresh`. Do not gate on `refreshTokenExpireTime`; the
|
||
* httpOnly cookie is the source of truth.
|
||
*/
|
||
const canAttemptTokenRefresh = (): boolean => {
|
||
if (!isClient) return false
|
||
|
||
const userData = getStoredUser()
|
||
|
||
if (userData?.accessToken || userData?.userId) return true
|
||
|
||
return Boolean(Cookies.get(AUTH_CONFIG.cookieName))
|
||
}
|
||
|
||
const isTransientRefreshFailure = (error: unknown): boolean => axios.isAxiosError(error) && !error.response
|
||
|
||
const refreshAccessToken = async (): Promise<string | null> => {
|
||
if (!canAttemptTokenRefresh()) return null
|
||
|
||
const userData = getStoredUser()
|
||
const url = new URL(AUTH_CONFIG.refreshPath, AUTH_CONFIG.baseURL)
|
||
let data: RefreshApiResponse
|
||
|
||
const requestRefresh = () =>
|
||
axios.post<RefreshApiResponse>(
|
||
url.href,
|
||
{},
|
||
{
|
||
withCredentials: true,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
}
|
||
)
|
||
|
||
try {
|
||
data = (await requestRefresh()).data
|
||
} catch (error) {
|
||
// A VPN reconnect can drop the response after the server has already
|
||
// rotated the httpOnly cookie. The API permits one device-bound recovery
|
||
// retry in that short window; never retry HTTP responses.
|
||
if (isTransientRefreshFailure(error)) {
|
||
data = (await requestRefresh()).data
|
||
} else {
|
||
if (
|
||
axios.isAxiosError(error) &&
|
||
error.response?.status === 403 &&
|
||
(error.response.data as { code?: string } | undefined)?.code === 'ACCOUNT_SUSPENDED'
|
||
) {
|
||
setAccountSuspended(true)
|
||
}
|
||
|
||
throw error
|
||
}
|
||
}
|
||
|
||
const tokens = data.success ? (data.data ?? data.body) : null
|
||
|
||
if (!tokens?.accessToken) {
|
||
throw new Error(data.message ?? 'پاسخ تمدید نشست معتبر نیست')
|
||
}
|
||
|
||
const decodedAccess = jwtDecode<DecodedToken>(tokens.accessToken)
|
||
const refreshExpireTime = parseRefreshExpiry(tokens.expiresAt)
|
||
|
||
const tokenStorage = createStorageData(tokens.accessToken, decodedAccess, refreshExpireTime, tokens.sessionId)
|
||
|
||
persistUserStorage({ ...userData, ...tokenStorage })
|
||
setAccessCookie(tokens.accessToken, decodedAccess.exp * 1000)
|
||
|
||
// `localStorage.setItem` only fires the native `storage` event in *other*
|
||
// tabs, not this one — AuthContext's `user` state (and anything derived
|
||
// from it, e.g. the chat WebSocket's auth token in useChatSocket) would
|
||
// otherwise stay stale until a full page reload. See docs/prompt/_shared.md.
|
||
if (isClient) window.dispatchEvent(new Event('userChanged'))
|
||
|
||
return tokens.accessToken
|
||
}
|
||
|
||
/**
|
||
* Serialize refresh-token rotation across same-origin tabs. The module-level
|
||
* promise only protects one JavaScript runtime; without this lock, two tabs
|
||
* can submit the same rotating cookie and successively revoke each other's
|
||
* freshly-issued sessions.
|
||
*/
|
||
const refreshAccessTokenAcrossTabs = async (): Promise<string | null> => {
|
||
const observedAccessToken = getStoredUser()?.accessToken ?? null
|
||
const runAfterLock = async (): Promise<string | null> => {
|
||
const current = getStoredUser()
|
||
|
||
// Another tab refreshed while this tab was waiting for the lock. Reuse
|
||
// its published access token instead of rotating the shared cookie again.
|
||
if (current?.accessToken && current.accessToken !== observedAccessToken && !isTokenExpired(current.AccessTokenExpireTime)) {
|
||
setAccessCookie(current.accessToken, current.AccessTokenExpireTime)
|
||
|
||
return current.accessToken
|
||
}
|
||
|
||
return refreshAccessToken()
|
||
}
|
||
|
||
if (!isClient || !navigator.locks) return runAfterLock()
|
||
|
||
return navigator.locks.request(AUTH_REFRESH_LOCK_NAME, runAfterLock)
|
||
}
|
||
|
||
/**
|
||
* Exported so `AuthContext` can call this proactively (before the short-lived
|
||
* access token expires) instead of only reactively on a 401. See the
|
||
* scheduler in AuthContext.tsx for why that matters — proxy.ts checks the
|
||
* `accessToken` cookie and the Nest httpOnly refresh cookie on every
|
||
* navigation, including soft ones, and a stale access cookie there forces a
|
||
* full-page reload.
|
||
*
|
||
* Never rejects: refresh failures resolve to `null` so fire-and-forget callers
|
||
* cannot leak `unhandledrejection` (Sentry). Use
|
||
* `refreshAccessTokenProactively` when a failed rotation should soft-expire
|
||
* the session instead of only returning null.
|
||
*/
|
||
export const getOrRefreshAccessToken = async (): Promise<string | null> => {
|
||
refreshPromise ??= refreshAccessTokenAcrossTabs()
|
||
.catch(() => null)
|
||
.finally(() => {
|
||
refreshPromise = null
|
||
})
|
||
|
||
return refreshPromise
|
||
}
|
||
|
||
/**
|
||
* Proactive access-token rotation for the AuthContext expiry timer.
|
||
* On failure runs the same soft session-expiry path as a 401 interceptor
|
||
* (clear local auth, expire refresh cookie, open AuthGate / `/auth`).
|
||
* Never rejects — safe for `void refreshAccessTokenProactively()`.
|
||
*/
|
||
export const refreshAccessTokenProactively = async (): Promise<void> => {
|
||
const token = await getOrRefreshAccessToken()
|
||
|
||
if (token) return
|
||
|
||
try {
|
||
await handleSessionExpired()
|
||
} catch {
|
||
// handleSessionExpired always throws after cleanup; swallow for timers.
|
||
}
|
||
}
|
||
|
||
const axiosInstance: AxiosInstance = axios.create({
|
||
baseURL: AUTH_CONFIG.baseURL,
|
||
withCredentials: true,
|
||
})
|
||
|
||
function attachAuthorization(config: InternalAxiosRequestConfig, token: string, cookieExpiryTime?: number): InternalAxiosRequestConfig {
|
||
const headers = AxiosHeaders.from(config.headers)
|
||
|
||
headers.set('Authorization', `Bearer ${token}`)
|
||
config.headers = headers
|
||
if (cookieExpiryTime) setAccessCookie(token, cookieExpiryTime)
|
||
|
||
return config
|
||
}
|
||
|
||
/**
|
||
* Relative `url` values resolve against our API `baseURL` and are always
|
||
* treated as same-origin backend requests (JWT attached). Absolute URLs are
|
||
* only same-backend when their origin matches `NEXT_PUBLIC_API_URL` — used so
|
||
* bearer tokens are never sent to unrelated hosts.
|
||
*/
|
||
function isSameBackendRequest(config: { url?: string }): boolean {
|
||
const url = config.url ?? ''
|
||
|
||
if (!/^https?:\/\//i.test(url)) return true
|
||
|
||
const apiBaseUrl = AUTH_CONFIG.baseURL
|
||
|
||
if (!apiBaseUrl) return false
|
||
|
||
try {
|
||
return new URL(url).origin === new URL(apiBaseUrl).origin
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function handleUserWithStorageToken(config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> {
|
||
const userData = getStoredUser()
|
||
|
||
if (!userData?.accessToken) return config
|
||
|
||
const { accessToken, AccessTokenExpireTime } = userData
|
||
|
||
// Queue new requests while token refresh is in flight (including 401 path)
|
||
if (refreshPromise) {
|
||
const refreshedToken = await tryRefreshAccessToken()
|
||
|
||
if (!refreshedToken) {
|
||
return handleSessionExpired()
|
||
}
|
||
|
||
return attachAuthorization(config, refreshedToken)
|
||
}
|
||
|
||
if (!isTokenExpired(AccessTokenExpireTime)) {
|
||
return attachAuthorization(config, accessToken, AccessTokenExpireTime)
|
||
}
|
||
|
||
const newToken = await tryRefreshAccessToken()
|
||
|
||
if (!newToken) {
|
||
return handleSessionExpired()
|
||
}
|
||
|
||
axiosInstance.defaults.headers.Authorization = `Bearer ${newToken}`
|
||
|
||
return attachAuthorization(config, newToken)
|
||
}
|
||
|
||
async function handleUserWithCookieToken(
|
||
config: InternalAxiosRequestConfig,
|
||
accessTokenCookie: string
|
||
): Promise<InternalAxiosRequestConfig> {
|
||
try {
|
||
const decodedAccessToken = jwtDecode<DecodedToken>(accessTokenCookie)
|
||
|
||
if (!isTokenExpired(decodedAccessToken.exp * 1000)) {
|
||
return attachAuthorization(config, accessTokenCookie, decodedAccessToken.exp * 1000)
|
||
}
|
||
} catch {
|
||
// Fall through to refresh when the cookie JWT is malformed/expired.
|
||
}
|
||
|
||
// Cookie-only sessions (no localStorage user yet) must still rotate via
|
||
// the httpOnly refresh cookie before clearing auth — otherwise an expired
|
||
// access cookie logs the user out even when refresh is still valid.
|
||
if (!canAttemptTokenRefresh()) {
|
||
return handleSessionExpired()
|
||
}
|
||
|
||
const newToken = await tryRefreshAccessToken()
|
||
|
||
if (!newToken) {
|
||
return handleSessionExpired()
|
||
}
|
||
|
||
axiosInstance.defaults.headers.Authorization = `Bearer ${newToken}`
|
||
|
||
return attachAuthorization(config, newToken)
|
||
}
|
||
|
||
axiosInstance.interceptors.request.use(
|
||
async (config: InternalAxiosRequestConfig) => {
|
||
if (!isSameBackendRequest(config)) {
|
||
config.withCredentials = false
|
||
|
||
return config
|
||
}
|
||
config.withCredentials = true
|
||
applyFormDataRequestDefaults(config)
|
||
if (!isClient) return config
|
||
|
||
const userData = getStoredUser()
|
||
|
||
if (userData?.accessToken) return handleUserWithStorageToken(config)
|
||
|
||
const accessTokenCookie = Cookies.get(AUTH_CONFIG.cookieName)
|
||
|
||
if (accessTokenCookie) return handleUserWithCookieToken(config, accessTokenCookie)
|
||
|
||
return config
|
||
},
|
||
(error: AxiosError) => Promise.reject(error)
|
||
)
|
||
|
||
const isRefreshTokenRequest = (config?: InternalAxiosRequestConfig): boolean => (config?.url ?? '').includes('auth/refresh')
|
||
|
||
const getErrorMessage = (error: AxiosError): string => {
|
||
const data = error.response?.data as { message?: string; code?: string } | undefined
|
||
|
||
return data?.code ?? data?.message ?? ''
|
||
}
|
||
|
||
const isSessionAuthFailure = (error: AxiosError): boolean => {
|
||
const message = getErrorMessage(error)
|
||
|
||
// Bare 401 (no code/message) is treated as a dead session.
|
||
if (!message) return true
|
||
|
||
return TOKEN_MESSAGES.accessInvalid.has(message) || message === 'Unauthorized'
|
||
}
|
||
|
||
const shouldTryRefreshFor401 = (error: AxiosError, config: RequestConfigWithRetry): boolean => {
|
||
if (isRefreshTokenRequest(config)) return false
|
||
if (config._retry) return false
|
||
if (!canAttemptTokenRefresh()) return false
|
||
if (!isSessionAuthFailure(error)) return false
|
||
|
||
return true
|
||
}
|
||
|
||
const getRequestBearerToken = (config: InternalAxiosRequestConfig): string | null => {
|
||
const authorization = AxiosHeaders.from(config.headers).get('Authorization')
|
||
|
||
if (typeof authorization !== 'string') return null
|
||
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim())
|
||
|
||
return match?.[1] ?? null
|
||
}
|
||
|
||
/**
|
||
* A refresh revokes the old refresh-token row while requests using its access
|
||
* token may still be in flight. If a newer token has already been published,
|
||
* replay the late 401 with that token without rotating the cookie yet again.
|
||
*/
|
||
const retryWithNewerStoredToken = (error: AxiosError, config: RequestConfigWithRetry): Promise<AxiosResponse> | null => {
|
||
if (config._authTokenSyncRetry || !isSessionAuthFailure(error)) return null
|
||
|
||
const requestToken = getRequestBearerToken(config)
|
||
const current = getStoredUser()
|
||
|
||
if (!requestToken || !current?.accessToken || current.accessToken === requestToken || isTokenExpired(current.AccessTokenExpireTime)) {
|
||
return null
|
||
}
|
||
|
||
config._authTokenSyncRetry = true
|
||
config.headers = AxiosHeaders.from(config.headers).set('Authorization', `Bearer ${current.accessToken}`)
|
||
|
||
return axiosInstance.request(config)
|
||
}
|
||
|
||
async function handle401Response(error: AxiosError): Promise<AxiosResponse> {
|
||
const config = error.config as RequestConfigWithRetry | undefined
|
||
|
||
if (!config) {
|
||
// بدون config نمیتوان retry کرد؛ 401 نشست را expire کن، بقیه (مثلاً OTP) را رد کن.
|
||
if (isSessionAuthFailure(error)) {
|
||
return handleSessionExpired()
|
||
}
|
||
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
if (isRefreshTokenRequest(config)) {
|
||
return handleSessionExpired()
|
||
}
|
||
|
||
const retryWithCurrentToken = retryWithNewerStoredToken(error, config)
|
||
|
||
if (retryWithCurrentToken) return retryWithCurrentToken
|
||
|
||
if (!shouldTryRefreshFor401(error, config)) {
|
||
// قبلاً فقط reject میشد → داشبورد ارور میدید ولی لاگاوت نمیشد
|
||
// (مثلاً 401 بدون توکن، یا retry بعد از refresh که باز هم UNAUTHORIZED بود).
|
||
// OTP_INVALID و سایر 401های کسبوکاری نباید نشست را پاک کنند.
|
||
if (isSessionAuthFailure(error)) {
|
||
return handleSessionExpired()
|
||
}
|
||
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
config._retry = true
|
||
|
||
try {
|
||
const newToken = await tryRefreshAccessToken()
|
||
|
||
if (!newToken) throw new Error('تمدید نشست ناموفق بود')
|
||
|
||
config.headers = AxiosHeaders.from(config.headers).set('Authorization', `Bearer ${newToken}`)
|
||
|
||
return await axiosInstance.request(config)
|
||
} catch (_refreshError) {
|
||
return handleSessionExpired()
|
||
}
|
||
}
|
||
|
||
axiosInstance.interceptors.response.use(
|
||
(response: AxiosResponse) => {
|
||
const path = (response.config.url ?? '').split('?')[0]
|
||
|
||
// A successful authenticated profile request is authoritative: the
|
||
// account has been reactivated since the suspension was first observed.
|
||
if (/\/?users\/me\/?$/.test(path)) setAccountSuspended(false)
|
||
|
||
return response
|
||
},
|
||
async (error: AxiosError) => {
|
||
const rawPath = (error.config?.url ?? 'unknown').split('?')[0]
|
||
const endpoint = rawPath.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, ':id').replace(/\/\d+(?=\/|$)/g, '/:id')
|
||
|
||
const status = error.response?.status ?? 0
|
||
|
||
if (status === 403 && getErrorMessage(error) === 'ACCOUNT_SUSPENDED') {
|
||
setAccountSuspended(true)
|
||
}
|
||
|
||
// Only surface server/network failures to Sentry — expected 4xx (auth,
|
||
// validation, not-found) would otherwise flood Issues.
|
||
if (!error.response || status >= 500) {
|
||
reportBrowserEvent({
|
||
name: 'api_failure',
|
||
level: 'error',
|
||
attributes: {
|
||
endpoint: endpoint.slice(0, 160),
|
||
method: error.config?.method?.toUpperCase() ?? 'UNKNOWN',
|
||
status,
|
||
},
|
||
})
|
||
}
|
||
|
||
if (error.response?.status === 401 && isSameBackendRequest(error.config ?? {})) {
|
||
try {
|
||
return await handle401Response(error)
|
||
} catch (refreshError) {
|
||
if (refreshError instanceof AxiosError && refreshError.response) {
|
||
refreshError.response.data = localizeApiErrorPayload(refreshError.response.data, refreshError.response.status)
|
||
}
|
||
|
||
return Promise.reject(refreshError instanceof Error ? refreshError : new Error('تمدید نشست ناموفق بود'))
|
||
}
|
||
}
|
||
|
||
if (error.response) {
|
||
error.response.data = localizeApiErrorPayload(error.response.data, error.response.status)
|
||
}
|
||
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
export default axiosInstance
|