'use client'; import * as React from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { apiFetch, ApiError, AUTH_REFRESH_KEY, AUTH_SESSION_KEY, AUTH_TOKEN_KEY, AUTH_USER_CACHE_KEY, AuthSessionResponse, AuthTokens, fetchAuthSession, getDeviceFingerprint, IdentifyResponse, getApiErrorMessage, isPinRequiredError, OtpSendResponse, PasswordlessAuthResponse, PinVerificationResponse, PublicUser, refreshAuthSession, resetPinRequiredNotification, setPinRequiredHandler, syncFedcmSession } from '@/lib/api'; import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge'; interface AuthContextValue { user: PublicUser | null; token: string | null; isLoading: boolean; isPinLocked: boolean; hasStoredSession: boolean; login: (login: string, password: string) => Promise; identifyLogin: (login: string) => Promise; sendLoginOtp: (recipient: string, channel?: string) => Promise; verifyLoginOtp: (recipient: string, code: string) => Promise; loginWithPassword: (login: string, password: string) => Promise; loginWithLdap: (username: string, password: string) => Promise; beginTotpLogin: (recipient: string) => Promise; verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise; completePin: (sessionId: string, pin: string) => Promise; applyLoginAuth: (auth: AuthTokens) => AuthTokens; register: (data: { displayName: string; login: string; password: string }) => Promise; refreshProfile: () => Promise; logout: () => void; } const AuthContext = React.createContext(null); function cacheUserProfile(user: PublicUser) { window.localStorage.setItem(AUTH_USER_CACHE_KEY, JSON.stringify(user)); } function readCachedUserProfile(): PublicUser | null { const raw = window.localStorage.getItem(AUTH_USER_CACHE_KEY); if (!raw) return null; try { return JSON.parse(raw) as PublicUser; } catch { return null; } } function persistPartialAuth(auth: AuthTokens) { window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken); window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId); cacheUserProfile(auth.user); } function applySessionState( session: AuthSessionResponse, setters: { setUser: React.Dispatch>; setToken: React.Dispatch>; activatePinLock: (sessionId: string) => void; clearPinLock: () => void; } ) { setters.setUser(session.user); cacheUserProfile(session.user); if (session.sessionId) { window.localStorage.setItem(AUTH_SESSION_KEY, session.sessionId); } if (session.requiresPin) { setters.activatePinLock(session.sessionId ?? ''); } else { setters.clearPinLock(); const accessToken = window.localStorage.getItem(AUTH_TOKEN_KEY); if (accessToken) { void syncFedcmSession(accessToken); } } } export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const { showToast } = useToast(); const [token, setToken] = React.useState(null); const [user, setUser] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [isPinLocked, setIsPinLocked] = React.useState(false); const [hasStoredSession, setHasStoredSession] = React.useState(false); const [lockedSessionId, setLockedSessionId] = React.useState(null); const [pinSubmitting, setPinSubmitting] = React.useState(false); const [pinError, setPinError] = React.useState(null); const isPinLockedRef = React.useRef(false); const refreshInFlightRef = React.useRef | null>(null); React.useEffect(() => { isPinLockedRef.current = isPinLocked; }, [isPinLocked]); const clearPinLock = React.useCallback(() => { setIsPinLocked(false); setLockedSessionId(null); setPinError(null); resetPinRequiredNotification(); }, []); React.useEffect(() => { function handleEmbeddedAuth(event: Event) { const detail = (event as CustomEvent).detail; if (!detail?.token?.trim()) return; setToken(detail.token.trim()); setHasStoredSession(true); clearPinLock(); } window.addEventListener(EMBEDDED_AUTH_MESSAGE, handleEmbeddedAuth as EventListener); return () => window.removeEventListener(EMBEDDED_AUTH_MESSAGE, handleEmbeddedAuth as EventListener); }, [clearPinLock]); const activatePinLock = React.useCallback((sessionId: string) => { const resolvedSessionId = sessionId || window.localStorage.getItem(AUTH_SESSION_KEY); if (!resolvedSessionId) return; setLockedSessionId(resolvedSessionId); setIsPinLocked(true); setPinError(null); setHasStoredSession(true); const cachedUser = readCachedUserProfile(); if (cachedUser) { setUser(cachedUser); } }, []); const saveSession = React.useCallback( (auth: AuthTokens) => { window.localStorage.setItem(AUTH_TOKEN_KEY, auth.accessToken); window.localStorage.setItem(AUTH_REFRESH_KEY, auth.refreshToken); window.localStorage.setItem(AUTH_SESSION_KEY, auth.sessionId); setToken(auth.accessToken); setUser(auth.user); cacheUserProfile(auth.user); setHasStoredSession(true); clearPinLock(); if (auth.pinVerified !== false) { void syncFedcmSession(auth.accessToken); } }, [clearPinLock] ); const logout = React.useCallback(() => { window.localStorage.removeItem(AUTH_TOKEN_KEY); window.localStorage.removeItem(AUTH_REFRESH_KEY); window.localStorage.removeItem(AUTH_SESSION_KEY); window.localStorage.removeItem(AUTH_USER_CACHE_KEY); setToken(null); setUser(null); setHasStoredSession(false); clearPinLock(); router.push('/auth/login'); }, [clearPinLock, router]); const refreshProfile = React.useCallback(async () => { if (refreshInFlightRef.current) { return refreshInFlightRef.current; } const task = (async () => { const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY); const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); setHasStoredSession(Boolean(refreshToken)); if (!currentToken && !refreshToken) { setIsLoading(false); return; } try { if (currentToken) { setToken(currentToken); try { const session = await fetchAuthSession(currentToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); return; } catch (error) { if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return; } if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') { throw error; } } } if (refreshToken) { const refreshed = await refreshAuthSession(); if (refreshed.accessToken) { window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken); setToken(refreshed.accessToken); } if (refreshed.sessionId) { window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId); } if (refreshed.requiresPin) { if (refreshed.user) { setUser(refreshed.user); cacheUserProfile(refreshed.user); } activatePinLock(refreshed.sessionId ?? ''); return; } if (refreshed.accessToken) { const session = await fetchAuthSession(refreshed.accessToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); return; } } throw new ApiError('Сессия недействительна', 401); } catch (error) { if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return; } if (refreshToken && error instanceof ApiError && error.status === 403) { activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return; } if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) { logout(); return; } const message = getApiErrorMessage(error, 'Не удалось загрузить профиль'); if (message) showToast(message); logout(); } finally { setIsLoading(false); refreshInFlightRef.current = null; } })(); refreshInFlightRef.current = task; return task; }, [activatePinLock, clearPinLock, logout, showToast]); React.useEffect(() => { setPinRequiredHandler(activatePinLock); return () => setPinRequiredHandler(null); }, [activatePinLock]); React.useEffect(() => { void refreshProfile(); }, [refreshProfile]); React.useEffect(() => { function handleVisibilityChange() { if (document.visibilityState !== 'visible') return; if (isPinLockedRef.current) return; if (!window.localStorage.getItem(AUTH_REFRESH_KEY)) return; void refreshProfile(); } function handleAuthRefreshed(event: Event) { const detail = (event as CustomEvent<{ accessToken?: string; user?: PublicUser }>).detail; if (detail.accessToken) { setToken(detail.accessToken); } if (detail.user) { setUser(detail.user); cacheUserProfile(detail.user); } } document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('lendry:auth-refreshed', handleAuthRefreshed); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('lendry:auth-refreshed', handleAuthRefreshed); }; }, [refreshProfile]); const login = React.useCallback( async (loginValue: string, password: string) => { const auth = await apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ login: loginValue, password, fingerprint: getDeviceFingerprint(), deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser', deviceType: 'WEB' }) }); if (auth.pinVerified) { saveSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [saveSession] ); const identifyLogin = React.useCallback(async (loginValue: string) => { return apiFetch('/auth/identify', { method: 'POST', body: JSON.stringify({ login: loginValue }) }); }, []); const sendLoginOtp = React.useCallback(async (recipient: string, channel?: string) => { return apiFetch('/auth/otp/send', { method: 'POST', body: JSON.stringify({ recipient, channel }) }); }, []); const verifyLoginOtp = React.useCallback( async (recipient: string, code: string) => { const response = await apiFetch('/auth/otp/verify', { method: 'POST', body: JSON.stringify({ recipient, code, fingerprint: getDeviceFingerprint(), deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser', deviceType: 'WEB' }) }); if (response.auth?.pinVerified) { saveSession(response.auth); } else if (response.auth) { persistPartialAuth(response.auth); setHasStoredSession(true); } return response; }, [saveSession] ); const loginWithPassword = React.useCallback( async (loginValue: string, passwordValue: string) => { const auth = await apiFetch('/auth/login/password', { method: 'POST', body: JSON.stringify({ login: loginValue, password: passwordValue, fingerprint: getDeviceFingerprint(), deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser', deviceType: 'WEB' }) }); if (auth.pinVerified) { saveSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [saveSession] ); const loginWithLdap = React.useCallback( async (username: string, passwordValue: string) => { const auth = await apiFetch('/auth/ldap/login', { method: 'POST', body: JSON.stringify({ username, password: passwordValue, fingerprint: getDeviceFingerprint(), deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser', deviceType: 'WEB' }) }); if (auth.pinVerified) { saveSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [saveSession] ); const beginTotpLogin = React.useCallback(async (recipient: string) => { const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', { method: 'POST', body: JSON.stringify({ recipient, fingerprint: getDeviceFingerprint(), deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser', deviceType: 'WEB' }) }); return response.totpChallengeToken; }, []); const verifyTotpLogin = React.useCallback( async (totpChallengeToken: string, code: string) => { const auth = await apiFetch('/auth/totp/verify', { method: 'POST', body: JSON.stringify({ totpChallengeToken, code }) }); if (auth.pinVerified) { saveSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [saveSession] ); const applyLoginAuth = React.useCallback( (auth: AuthTokens) => { if (auth.pinVerified) { saveSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); activatePinLock(auth.sessionId); } return auth; }, [activatePinLock, saveSession] ); const completePin = React.useCallback( async (sessionId: string, pin: string) => { const response = await apiFetch('/auth/pin/verify', { method: 'POST', body: JSON.stringify({ sessionId, pin }) }); window.localStorage.setItem(AUTH_TOKEN_KEY, response.accessToken); window.localStorage.setItem(AUTH_SESSION_KEY, sessionId); setToken(response.accessToken); const session = await fetchAuthSession(response.accessToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); if (pathname.startsWith('/auth/')) { router.replace('/'); } }, [activatePinLock, clearPinLock, pathname, router] ); const handlePinUnlock = React.useCallback( async (pin: string) => { const sessionId = lockedSessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY); if (!sessionId) { setPinError('Сессия не найдена. Войдите снова.'); return; } setPinSubmitting(true); setPinError(null); try { await completePin(sessionId, pin); } catch (error) { setPinError(error instanceof Error ? error.message : 'Не удалось подтвердить PIN-код'); } finally { setPinSubmitting(false); } }, [completePin, lockedSessionId] ); const register = React.useCallback( async (data: { displayName: string; login: string; password: string }) => { const isEmail = data.login.includes('@'); await apiFetch('/auth/register', { method: 'POST', body: JSON.stringify({ displayName: data.displayName, password: data.password, ...(isEmail ? { email: data.login } : { phone: data.login }) }) }); await login(data.login, data.password); }, [login] ); return ( {children} ); } export function useAuth() { const context = React.useContext(AuthContext); if (!context) { throw new Error('useAuth должен использоваться внутри AuthProvider'); } return context; }