'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(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(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) => { 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 ( {children} ) } export { RESEND_COOLDOWN_SECONDS }