Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
520 lines
16 KiB
TypeScript
520 lines
16 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']),
|
|
refreshInvalid: new Set(['REFRESH_TOKEN_INVALID', 'REFRESH_TOKEN_EXPIRED', 'UNAUTHORIZED']),
|
|
}
|
|
|
|
type RequestConfigWithRetry = InternalAxiosRequestConfig & { _retry?: 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'
|
|
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
|
|
}
|
|
|
|
void _legacyRefreshToken
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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 ??= refreshAccessToken()
|
|
.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 shouldTryRefreshFor401 = (error: AxiosError, config: RequestConfigWithRetry): boolean => {
|
|
if (isRefreshTokenRequest(config)) return false
|
|
if (config._retry) return false
|
|
if (!canAttemptTokenRefresh()) return false
|
|
|
|
const message = getErrorMessage(error)
|
|
|
|
if (TOKEN_MESSAGES.refreshInvalid.has(message) && isRefreshTokenRequest(config)) return false
|
|
if (!message) return true
|
|
|
|
return TOKEN_MESSAGES.accessInvalid.has(message) || message === 'Unauthorized'
|
|
}
|
|
|
|
async function handle401Response(error: AxiosError): Promise<AxiosResponse> {
|
|
const config = error.config as RequestConfigWithRetry | undefined
|
|
|
|
if (!config) {
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
if (isRefreshTokenRequest(config)) {
|
|
return handleSessionExpired()
|
|
}
|
|
|
|
if (!shouldTryRefreshFor401(error, config)) {
|
|
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
|