'use client'; import * as React from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { apiFetch, ApiError, AUTH_SESSION_INVALID_EVENT, AUTH_REFRESH_KEY, AUTH_SESSION_KEY, AUTH_TOKEN_KEY, AUTH_USER_CACHE_KEY, AuthSessionResponse, AuthTokens, fetchAuthSession, buildAuthDevicePayload, IdentifyResponse, getApiErrorMessage, ensureApiGatewayReady, invalidateApiGatewayReady, isApiGatewayReady, isGatewayUnavailableError, isPinRequiredError, OtpSendResponse, PasswordlessAuthResponse, PinVerificationResponse, PublicUser, refreshAuthSession, resetGatewayCircuit, resetPinRequiredNotification, scheduleFedcmSessionSync, setPinRequiredHandler, subscribeApiReady, subscribeApiNotReady, } from '@/lib/api'; import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; import { AppBootstrapScreen } from './app-bootstrap-screen'; import { EMBEDDED_AUTH_MESSAGE, type EmbeddedAuthPayload } from '@/lib/embedded-auth-bridge'; interface AuthContextValue { user: PublicUser | null; token: string | null; isLoading: boolean; isApiReady: boolean; /** true после стабильного /health и успешной проверки сессии */ isSessionReady: boolean; /** @deprecated используйте isSessionReady */ isContentReady: 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) => Promise; applyUserPatch: (patch: Partial) => void; 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(); } } function readInitialAuthState() { // Первичный render должен совпадать на сервере и в браузере. Токены из // localStorage читаются уже в refreshProfile(), иначе авторизованная вкладка // получает hydration mismatch (React #418) ещё до запуска эффектов. return { token: null as string | null, hasStoredSession: false }; } export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const { showToast } = useToast(); const initialAuth = React.useMemo(() => readInitialAuthState(), []); const [token, setToken] = React.useState(initialAuth.token); const [user, setUser] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [isApiReady, setIsApiReady] = React.useState(false); const [isSessionReady, setIsSessionReady] = React.useState(false); const [isPinLocked, setIsPinLocked] = React.useState(false); const [hasStoredSession, setHasStoredSession] = React.useState(initialAuth.hasStoredSession); const [lockedSessionId, setLockedSessionId] = React.useState(null); const [pinSubmitting, setPinSubmitting] = React.useState(false); const [pinError, setPinError] = React.useState(null); const [bootstrapError, setBootstrapError] = React.useState(null); const isPinLockedRef = React.useRef(false); const refreshInFlightRef = React.useRef | null>(null); const initialBootstrapDoneRef = React.useRef(false); React.useEffect(() => { if (isApiGatewayReady()) { setIsApiReady(true); } const onReady = () => setIsApiReady(true); const onNotReady = () => { if (!initialBootstrapDoneRef.current) { setIsApiReady(false); } }; const unsubReady = subscribeApiReady(onReady); const unsubNotReady = subscribeApiNotReady(onNotReady); return () => { unsubReady(); unsubNotReady(); }; }, []); 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 warmUpAndApplySession = React.useCallback( async (accessToken: string) => { setIsSessionReady(false); setIsLoading(true); setBootstrapError(null); invalidateApiGatewayReady(); setIsApiReady(false); try { await ensureApiGatewayReady(); const session = await fetchAuthSession(accessToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); initialBootstrapDoneRef.current = true; setIsApiReady(true); setIsSessionReady(true); } catch (error) { setBootstrapError( isGatewayUnavailableError(error) ? 'Не удалось подключиться к серверу API. Подождите несколько секунд и нажмите «Повторить».' : (getApiErrorMessage(error, 'Не удалось подключиться к серверу API') ?? 'Ошибка подключения') ); throw error; } finally { setIsLoading(false); } }, [activatePinLock, clearPinLock] ); const establishSession = React.useCallback( async (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(); setBootstrapError(null); initialBootstrapDoneRef.current = true; setIsApiReady(true); setIsSessionReady(true); setIsLoading(false); void fetchAuthSession(auth.accessToken) .then((session) => { applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); }) .catch((error) => { if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? auth.sessionId); return; } if (!isGatewayUnavailableError(error)) { const message = getApiErrorMessage(error, 'Не удалось обновить профиль после входа'); if (message) showToast(message); } }); if (auth.pinVerified !== false) { scheduleFedcmSessionSync(auth.accessToken, 8000); } }, [activatePinLock, clearPinLock, showToast] ); 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); initialBootstrapDoneRef.current = false; setToken(null); setUser(null); setHasStoredSession(false); setIsSessionReady(false); setIsApiReady(false); invalidateApiGatewayReady(); clearPinLock(); router.push('/auth/login'); }, [clearPinLock, router]); React.useEffect(() => { function handleInvalidSession() { if (!window.localStorage.getItem(AUTH_TOKEN_KEY) && !window.localStorage.getItem(AUTH_REFRESH_KEY)) return; logout(); } window.addEventListener(AUTH_SESSION_INVALID_EVENT, handleInvalidSession); return () => window.removeEventListener(AUTH_SESSION_INVALID_EVENT, handleInvalidSession); }, [logout]); const applyUserPatch = React.useCallback((patch: Partial) => { setUser((current) => { if (!current) return current; const next = { ...current, ...patch }; cacheUserProfile(next); return next; }); }, []); const refreshProfile = React.useCallback(async () => { if (refreshInFlightRef.current) { return refreshInFlightRef.current; } const task: Promise = (async () => { const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY); const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); setHasStoredSession(Boolean(currentToken || refreshToken)); if (!currentToken && !refreshToken) { window.localStorage.removeItem(AUTH_USER_CACHE_KEY); setUser(null); setToken(null); initialBootstrapDoneRef.current = true; setIsSessionReady(true); setIsApiReady(true); setIsLoading(false); return; } const isInitialBootstrap = !initialBootstrapDoneRef.current; if (isInitialBootstrap) { setIsLoading(true); setBootstrapError(null); } try { for (let bootstrapAttempt = 0; bootstrapAttempt < 4; bootstrapAttempt += 1) { if (isInitialBootstrap) { try { await ensureApiGatewayReady(bootstrapAttempt > 0); } catch { // цикл bootstrap повторит после паузы } } try { if (currentToken) { setToken(currentToken); try { const session = await fetchAuthSession(currentToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); setIsApiReady(true); setIsSessionReady(true); return; } catch (error) { if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); setIsApiReady(true); setIsSessionReady(true); 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 ?? ''); setIsApiReady(true); setIsSessionReady(true); return; } if (refreshed.accessToken) { const session = await fetchAuthSession(refreshed.accessToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); setIsApiReady(true); setIsSessionReady(true); return; } } throw new ApiError('Сессия недействительна', 401); } catch (error) { if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 4) { invalidateApiGatewayReady(); resetGatewayCircuit(); setBootstrapError( 'Не удалось подключиться к серверу API (502). Подождите несколько секунд и нажмите «Повторить».' ); await new Promise((resolve) => setTimeout(resolve, 1200)); continue; } if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); setIsApiReady(true); setIsSessionReady(true); return; } if (refreshToken && error instanceof ApiError && error.status === 403) { activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); setIsApiReady(true); setIsSessionReady(true); return; } if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) { logout(); return; } const message = getApiErrorMessage(error, 'Не удалось загрузить профиль'); if (isGatewayUnavailableError(error) && isInitialBootstrap) { setBootstrapError('Не удалось подключиться к серверу API. Подождите несколько секунд и нажмите «Повторить».'); setIsApiReady(false); setIsSessionReady(false); setIsLoading(false); return; } if (message) showToast(message); logout(); return; } } } finally { if (isInitialBootstrap) { initialBootstrapDoneRef.current = true; setIsLoading(false); } } })(); refreshInFlightRef.current = task; void task.finally(() => { refreshInFlightRef.current = null; }); 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, ...buildAuthDevicePayload(), }) }); if (auth.pinVerified) { await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [establishSession] ); 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, ...buildAuthDevicePayload(), }) }); if (response.auth?.pinVerified) { await establishSession(response.auth); } else if (response.auth) { persistPartialAuth(response.auth); setHasStoredSession(true); } return response; }, [establishSession] ); 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, ...buildAuthDevicePayload(), }) }); if (auth.pinVerified) { await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [establishSession] ); const loginWithLdap = React.useCallback( async (username: string, passwordValue: string) => { const auth = await apiFetch('/auth/ldap/login', { method: 'POST', body: JSON.stringify({ username, password: passwordValue, ...buildAuthDevicePayload(), }) }); if (auth.pinVerified) { await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [establishSession] ); const beginTotpLogin = React.useCallback(async (recipient: string) => { const response = await apiFetch<{ totpChallengeToken: string }>('/auth/totp/begin', { method: 'POST', body: JSON.stringify({ recipient, ...buildAuthDevicePayload(), }) }); 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) { await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, [establishSession] ); const applyLoginAuth = React.useCallback( async (auth: AuthTokens) => { if (auth.pinVerified) { await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); activatePinLock(auth.sessionId); } return auth; }, [activatePinLock, establishSession] ); 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); try { await warmUpAndApplySession(response.accessToken); } catch (error) { if (!isGatewayUnavailableError(error)) { throw error; } const cachedUser = readCachedUserProfile(); if (cachedUser) { setUser(cachedUser); } clearPinLock(); setBootstrapError(null); initialBootstrapDoneRef.current = true; setIsApiReady(true); setIsSessionReady(true); setIsLoading(false); } if (pathname.startsWith('/auth/')) { router.replace('/'); } }, [clearPinLock, pathname, router, warmUpAndApplySession] ); 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] ); const isBootstrapping = !isPinLocked && Boolean(hasStoredSession || token) && (isLoading || !isApiReady || !isSessionReady); React.useEffect(() => { if (!isBootstrapping || bootstrapError) return; const timer = window.setTimeout(() => { setBootstrapError( 'Сервер API не отвечает. Если недавно перезапускали контейнеры — подождите 10–20 секунд и нажмите «Повторить».' ); }, 18000); return () => window.clearTimeout(timer); }, [bootstrapError, isBootstrapping]); return ( {isBootstrapping ? ( { setBootstrapError(null); initialBootstrapDoneRef.current = false; invalidateApiGatewayReady(); resetGatewayCircuit(); setIsLoading(true); setIsApiReady(false); setIsSessionReady(false); void refreshProfile(); }} /> ) : ( children )} ); } export function useAuth() { const context = React.useContext(AuthContext); if (!context) { throw new Error('useAuth должен использоваться внутри AuthProvider'); } return context; }