- 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.
345 lines
11 KiB
TypeScript
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 }
|