admin/context/AuthContext.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

379 lines
12 KiB
TypeScript

'use client'
import { hashKey } from '@tanstack/react-query'
import { jwtDecode } from 'jwt-decode'
import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'
import Cookies from 'js-cookie'
import type {
SendOtpData,
CheckOtpData,
User,
SaveUserData,
AuthContextType,
DecodedToken,
CompleteProfileData,
UserAccountStatus,
} from '@/types'
import { addToast } from '@/lib/toast'
import { attemptServerLogout, expireRefreshSessionOnThisOrigin } from '@/lib/logoutSession'
import { parseRefreshExpiry, REFRESH_COOKIE_DAYS } from '@/lib/refreshTokenExpiry'
import { convertPersianToEnglish } from '@/helpers'
import { texts } from '@/texts'
import { REQUEST_OTP, VERIFY_OTP } from '@/services/auth'
import { COMPLETE_PROFILE, type UserProfile } from '@/services/users'
import { unsubscribeCurrentDevicePush } from '@/services/push'
import axiosInstance, { refreshAccessTokenProactively } from '@/config/axios'
import useLoading from '@/hooks/useLoading'
import { clearAsyncResourceCache } from '@/hooks/useAsyncResource'
import { clearPersistedQueryCache, getQueryClient } from '@/lib/queryClient'
import { consumerKeys } from '@/queries/consumerKeys'
import { setAccountSuspended } from '@/lib/accountSuspension'
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
const RESEND_COOLDOWN_SECONDS = 120
const setStatusCookie = (status?: UserAccountStatus) => {
if (status) {
Cookies.set('userStatus', status, { expires: REFRESH_COOKIE_DAYS })
} else {
Cookies.remove('userStatus')
}
}
const setRoleCookie = (role?: User['role']) => {
// UX hint only (nav chrome). Middleware / RSC must not trust this for gating —
// see getRoleFromAccessToken in authRouting.ts / proxy.ts.
if (role) {
Cookies.set('userRole', role, { expires: REFRESH_COOKIE_DAYS })
} else {
Cookies.remove('userRole')
}
}
/**
* Write the JS-readable access cookie from an absolute expiry timestamp
* (ms since epoch). Shared by `saveUser` (fresh JWT) and
* `syncSessionFromStorage` (hydrated session) so the cookie-lifetime math
* cannot drift between the two paths.
*
* The refresh token is an httpOnly cookie set by Nest — never written here.
*/
const applySessionCookies = (params: { accessToken?: string; accessExpireAtMs?: number }) => {
const { accessToken, accessExpireAtMs } = params
if (accessToken && accessExpireAtMs != null) {
const accessExpireInSeconds = Math.floor((accessExpireAtMs - Date.now()) / 1000)
if (accessExpireInSeconds > 0) {
Cookies.set('accessToken', accessToken, { expires: accessExpireInSeconds / 86400, sameSite: 'lax' })
}
}
}
const clearLegacyRefreshCookie = () => {
Cookies.remove('refreshToken')
}
export const AuthProvider = ({ children }: AuthProviderProps) => {
const { setLoading } = useLoading()
const [user, setUser] = useState<User | null>(null)
const [isSessionHydrated, setIsSessionHydrated] = useState(false)
/** Read session from localStorage only — no profile API call */
const syncSessionFromStorage = useCallback(() => {
try {
setLoading(true)
const raw = localStorage.getItem('user')
if (!raw) {
setUser(null)
Cookies.remove('accessToken')
clearLegacyRefreshCookie()
setStatusCookie(undefined)
setRoleCookie(undefined)
return
}
const stored = JSON.parse(raw) as User & { refreshToken?: unknown }
delete stored.refreshToken
applySessionCookies({
accessToken: stored.accessToken,
accessExpireAtMs: stored.AccessTokenExpireTime,
})
clearLegacyRefreshCookie()
setStatusCookie(stored.status)
setRoleCookie(stored.role)
setUser(stored)
} catch {
setUser(null)
Cookies.remove('accessToken')
clearLegacyRefreshCookie()
setStatusCookie(undefined)
setRoleCookie(undefined)
} finally {
setLoading(false)
setIsSessionHydrated(true)
}
}, [setLoading])
useEffect(() => {
syncSessionFromStorage()
const handleUserChanged = () => {
syncSessionFromStorage()
}
const handleStorage = (event: StorageEvent) => {
if (event.key === 'user') syncSessionFromStorage()
}
window.addEventListener('userChanged', handleUserChanged)
window.addEventListener('storage', handleStorage)
return () => {
window.removeEventListener('userChanged', handleUserChanged)
window.removeEventListener('storage', handleStorage)
}
}, [syncSessionFromStorage])
/**
* `proxy.ts` checks the `accessToken` cookie and the Nest httpOnly refresh
* cookie (`__Host-ghabilee_refresh` / `ghabilee_refresh`) on every
* navigation — including soft/SPA ones — to decide whether a route needs
* auth. `accessToken` is a ~15m JWT (JWT_ACCESS_EXPIRES_IN); previously it
* only got refreshed reactively, on the next 401 from an actual API call.
* If a user navigated (no API call yet) after it lapsed, proxy.ts could
* see a stale cookie, 307 the soft navigation, and Next.js can only
* resolve that redirect with a full page reload — which resets the whole
* app (including the React Query cache), showing a loading skeleton for
* data that was already fetched. Refreshing shortly before expiry keeps
* the cookie perpetually valid during an active session so that path is
* never hit.
*/
useEffect(() => {
if (!user?.accessToken || !user.AccessTokenExpireTime) return
const REFRESH_MARGIN_MS = 60_000
const delay = Math.max(user.AccessTokenExpireTime - Date.now() - REFRESH_MARGIN_MS, 0)
const timer = window.setTimeout(() => {
// Must not use bare getOrRefreshAccessToken(): a 401 refresh used to
// reject unhandled (Sentry on /chats). This helper soft-expires instead.
void refreshAccessTokenProactively()
}, delay)
return () => {
window.clearTimeout(timer)
}
}, [user?.accessToken, user?.AccessTokenExpireTime])
const saveUser = (data: SaveUserData) => {
const { accessToken, sessionId, expiresAt, status } = data
axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`
const decodedAccessToken: DecodedToken = jwtDecode(accessToken)
const refreshExpireTime = parseRefreshExpiry(expiresAt)
const accessExpireAtMs = decodedAccessToken.exp * 1000
const storageData: User = {
accessToken,
userId: decodedAccessToken.sub,
role: decodedAccessToken.role,
sessionId: sessionId ?? decodedAccessToken.sessionId,
AccessTokenExpireTime: accessExpireAtMs,
refreshTokenExpireTime: refreshExpireTime,
status,
}
localStorage.setItem('user', JSON.stringify(storageData))
applySessionCookies({
accessToken,
accessExpireAtMs,
})
clearLegacyRefreshCookie()
setStatusCookie(status)
setRoleCookie(storageData.role)
setUser(storageData)
if (status === 'active') setAccountSuspended(false)
}
const resolveMobile = (mobile: string) => convertPersianToEnglish(mobile).trim()
const persistAuthTokens = (tokens: { accessToken: string; sessionId: string; expiresAt: string; status: UserAccountStatus }) => {
saveUser({
accessToken: tokens.accessToken,
sessionId: tokens.sessionId,
expiresAt: tokens.expiresAt,
status: tokens.status,
})
return tokens.status
}
const sendLoginOtp = async (data: SendOtpData) => {
const mobile = resolveMobile(data.mobile)
const otpResult = await REQUEST_OTP({ mobile }, { errorMode: 'silent' })
if (!otpResult.ok) throw otpResult.error
const alreadySent = otpResult.data.alreadySent
addToast({
title: alreadySent ? texts.auth.otpAlreadySent : texts.auth.otpSent,
color: alreadySent ? 'warning' : 'success',
})
return {
purpose: otpResult.data.purpose,
expiresIn: otpResult.data.expiresIn,
resendIn: RESEND_COOLDOWN_SECONDS,
alreadySent,
}
}
const checkLoginOtp = async (data: CheckOtpData) => {
const mobile = resolveMobile(data.mobile)
const tokens = await VERIFY_OTP(
{
mobile,
code: convertPersianToEnglish(data.code).trim(),
termsAccepted: data.termsAccepted,
},
{ errorMode: 'silent' }
)
if (!tokens.ok) throw tokens.error
return persistAuthTokens(tokens.data)
}
const completeProfile = async (data: CompleteProfileData) => {
const result = await COMPLETE_PROFILE(data, { errorMode: 'silent' })
if (!result.ok) throw result.error
updateUserFromOutside({
status: result.data.status,
firstName: result.data.firstName,
lastName: result.data.lastName,
gender: result.data.gender,
cityId: result.data.cityId,
avatarUrl: result.data.avatarUrl,
})
setStatusCookie(result.data.status)
}
const logout = async () => {
try {
if (user?.accessToken) {
// Only this device's subscription — logging out here shouldn't
// silence push notifications on the user's other logged-in devices.
await unsubscribeCurrentDevicePush()
}
await attemptServerLogout()
} catch {
// Nest logout is best-effort; local + httpOnly cookies still clear below.
} finally {
await expireRefreshSessionOnThisOrigin()
setUser(null)
Cookies.remove('accessToken')
clearLegacyRefreshCookie()
setStatusCookie(undefined)
setRoleCookie(undefined)
localStorage.removeItem('user')
getQueryClient().clear()
clearPersistedQueryCache()
clearAsyncResourceCache()
// Hard navigation is intentional: logout must reset the full client
// runtime (module-level axios refreshPromise, chat sockets, AuthGate
// modal state, React Query subscribers, etc.). Soft Next.js router
// navigation would leave those live. Same pattern as axios
// `redirectToLogin` (`window.location.replace`). E2e auth logout
// asserts localStorage/cookies cleared + land on `/`, not soft-nav safety.
window.location.replace('/')
addToast({
title: 'خروج موفقیت آمیز بود',
color: 'success',
})
}
}
const updateUserFromOutside = useCallback((data: Record<string, unknown>) => {
setUser((current) => {
if (!current) return current
const sanitizedData = { ...data }
delete sanitizedData.password
delete sanitizedData.confirmPassword
delete sanitizedData.refreshToken
const updatedData = { ...current, ...sanitizedData } as User & { refreshToken?: unknown }
delete updatedData.refreshToken
localStorage.setItem('user', JSON.stringify(updatedData))
if (typeof sanitizedData.status === 'string') {
setStatusCookie(sanitizedData.status as UserAccountStatus)
}
return updatedData
})
}, [])
/**
* `user` (this context) and the `consumerKeys.me()` query cache are two
* copies of the same profile fields — one bootstraps synchronously from
* localStorage, the other is the fresh server copy consumer pages read via
* `useMeQuery`. Rather than every mutation site remembering to patch both
* (easy to forget — profile/contact/page.tsx didn't, before this), mirror
* `me()` into `user` in one place whenever the query cache changes, however
* it changed (setQueryData, a refetch after invalidate, background refetch).
*/
useEffect(() => {
const queryClient = getQueryClient()
const meQueryHash = hashKey(consumerKeys.me())
return queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'updated' || event.query.queryHash !== meQueryHash) return
const profile = event.query.state.data as UserProfile | undefined
if (!profile) return
updateUserFromOutside({
firstName: profile.firstName,
lastName: profile.lastName,
gender: profile.gender,
cityId: profile.cityId,
avatarUrl: profile.avatarUrl,
bio: profile.bio,
defaultAddress: profile.defaultAddress,
})
})
}, [updateUserFromOutside])
return (
<AuthContext.Provider
value={{ user, isSessionHydrated, sendLoginOtp, checkLoginOtp, completeProfile, logout, saveUser, updateUserFromOutside }}
>
{children}
</AuthContext.Provider>
)
}
export { RESEND_COOLDOWN_SECONDS }