admin/context/AuthContext.tsx
alisaza 82593dc18c refactor(events): update event management components and remove unused files
- Replaced `LIST_PUBLIC_CATEGORIES` with `LIST_ADMIN_CATEGORIES_FLAT` in `ArticleFormModal.tsx`.
- Removed the "Add Event" button from the dashboard page.
- Simplified the `EventsPage` by removing the button and adjusting the layout.
- Cleaned up the `AdminEventEditPage` by removing unnecessary props.
- Deleted unused layout and page files related to event creation.
- Updated `AdminAuthContent` to use `useAdminCitiesQuery` instead of `useDiscoveryCitiesQuery`.
- Refactored `EventGuestListAccessPanel` to remove the `accessMode` prop and adjust API calls accordingly.
- Removed several unused components and tests related to event creation, enhancing project maintainability.
2026-09-07 18:09:55 +03:30

345 lines
11 KiB
TypeScript

'use client'
import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'
import { jwtDecode } from 'jwt-decode'
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 } 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 { 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
})
}, [])
return (
<AuthContext.Provider
value={{ user, isSessionHydrated, sendLoginOtp, checkLoginOtp, completeProfile, logout, saveUser, updateUserFromOutside }}
>
{children}
</AuthContext.Provider>
)
}
export { RESEND_COOLDOWN_SECONDS }