From df4bbba133c75cf3f74586863907ae79785aa945 Mon Sep 17 00:00:00 2001 From: lendry Date: Tue, 30 Jun 2026 11:28:10 +0300 Subject: [PATCH] fix and update --- apps/frontend/app/auth/login/page.tsx | 13 +- .../addresses/address-quick-section.tsx | 4 +- apps/frontend/components/id/auth-provider.tsx | 121 +++++++++++------- .../id/public-settings-provider.tsx | 3 +- apps/frontend/hooks/use-require-auth.ts | 9 +- apps/frontend/lib/api.ts | 58 ++++++++- 6 files changed, 139 insertions(+), 69 deletions(-) diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 9fe23c5..988f099 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -86,10 +86,10 @@ export default function LoginPage() { const redirectAfterLogin = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null; if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) { - router.push(redirectAfterLogin); + router.replace(redirectAfterLogin); return; } - router.push('/'); + router.replace('/'); }, [router]); const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth(); @@ -195,11 +195,10 @@ export default function LoginPage() { function finishQrAuth(auth: AuthTokens) { - - const resolved = applyLoginAuth(auth); - - finishAuth(resolved.pinVerified, resolved.sessionId); - + void (async () => { + const resolved = await applyLoginAuth(auth); + finishAuth(resolved.pinVerified, resolved.sessionId); + })(); } diff --git a/apps/frontend/components/addresses/address-quick-section.tsx b/apps/frontend/components/addresses/address-quick-section.tsx index dddab51..5d6a1c4 100644 --- a/apps/frontend/components/addresses/address-quick-section.tsx +++ b/apps/frontend/components/addresses/address-quick-section.tsx @@ -51,7 +51,7 @@ export function AddressQuickSection({ const [editingAddress, setEditingAddress] = useState(null); const loadAddresses = useCallback(async () => { - if (!user || !token || isPinLocked) return; + if (!user || !token || isPinLocked || !isReady) return; try { const response = await fetchUserAddresses(user.id, token); setAddresses(response.addresses ?? []); @@ -59,7 +59,7 @@ export function AddressQuickSection({ const message = getApiErrorMessage(error, 'Не удалось загрузить адреса'); if (message) showToast(message); } - }, [isPinLocked, showToast, token, user]); + }, [isPinLocked, isReady, showToast, token, user]); useEffect(() => { if (isReady && user && !isPinLocked) void loadAddresses(); diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 5556734..fde0cde 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -11,7 +11,6 @@ import { AUTH_USER_CACHE_KEY, AuthSessionResponse, AuthTokens, - ensureApiGatewayReady, fetchAuthSession, buildAuthDevicePayload, IdentifyResponse, @@ -29,6 +28,8 @@ import { scheduleFedcmSessionSync, setPinRequiredHandler, subscribeApiReady, + subscribeApiNotReady, + waitForStableGateway, } from '@/lib/api'; import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; @@ -40,7 +41,9 @@ interface AuthContextValue { token: string | null; isLoading: boolean; isApiReady: boolean; - /** false первые ~600 ms после bootstrap — блокирует шторм API сразу после логина */ + /** true после стабильного /health и успешной проверки сессии */ + isSessionReady: boolean; + /** @deprecated используйте isSessionReady */ isContentReady: boolean; isPinLocked: boolean; hasStoredSession: boolean; @@ -53,7 +56,7 @@ interface AuthContextValue { beginTotpLogin: (recipient: string) => Promise; verifyTotpLogin: (totpChallengeToken: string, code: string) => Promise; completePin: (sessionId: string, pin: string) => Promise; - applyLoginAuth: (auth: AuthTokens) => AuthTokens; + applyLoginAuth: (auth: AuthTokens) => Promise; register: (data: { displayName: string; login: string; password: string }) => Promise; refreshProfile: () => Promise; logout: () => void; @@ -111,7 +114,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [isApiReady, setIsApiReady] = React.useState(false); - const [isContentReady, setIsContentReady] = React.useState(false); + const [isSessionReady, setIsSessionReady] = React.useState(false); const [isPinLocked, setIsPinLocked] = React.useState(false); const [hasStoredSession, setHasStoredSession] = React.useState(false); const [lockedSessionId, setLockedSessionId] = React.useState(null); @@ -125,9 +128,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (isApiGatewayReady()) { setIsApiReady(true); } - return subscribeApiReady(() => { - setIsApiReady(true); - }); + const onReady = () => setIsApiReady(true); + const onNotReady = () => { + setIsApiReady(false); + setIsSessionReady(false); + }; + const unsubReady = subscribeApiReady(onReady); + const unsubNotReady = subscribeApiNotReady(onNotReady); + return () => { + unsubReady(); + unsubNotReady(); + }; }, []); React.useEffect(() => { @@ -167,8 +178,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []); - const saveSession = React.useCallback( - (auth: AuthTokens) => { + const warmUpAndApplySession = React.useCallback( + async (accessToken: string) => { + setIsSessionReady(false); + setIsLoading(true); + invalidateApiGatewayReady(); + setIsApiReady(false); + + try { + await waitForStableGateway(3); + const session = await fetchAuthSession(accessToken); + applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + initialBootstrapDoneRef.current = true; + setIsApiReady(true); + setIsSessionReady(true); + } 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); @@ -177,15 +209,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { cacheUserProfile(auth.user); setHasStoredSession(true); clearPinLock(); - initialBootstrapDoneRef.current = true; - setIsApiReady(true); - setIsLoading(false); - setIsContentReady(false); + + await warmUpAndApplySession(auth.accessToken); + if (auth.pinVerified !== false) { scheduleFedcmSessionSync(auth.accessToken, 2500); } }, - [clearPinLock] + [clearPinLock, warmUpAndApplySession] ); const logout = React.useCallback(() => { @@ -197,6 +228,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setToken(null); setUser(null); setHasStoredSession(false); + setIsSessionReady(false); + setIsApiReady(false); + invalidateApiGatewayReady(); clearPinLock(); router.push('/auth/login'); }, [clearPinLock, router]); @@ -213,6 +247,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (!currentToken && !refreshToken) { initialBootstrapDoneRef.current = true; + setIsSessionReady(true); setIsLoading(false); return; } @@ -226,7 +261,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) { try { if (isInitialBootstrap) { - await ensureApiGatewayReady(); + await waitForStableGateway(3); } if (currentToken) { @@ -234,6 +269,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { try { const session = await fetchAuthSession(currentToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + setIsSessionReady(true); return; } catch (error) { if (isPinRequiredError(error)) { @@ -266,6 +302,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (refreshed.accessToken) { const session = await fetchAuthSession(refreshed.accessToken); applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + setIsSessionReady(true); return; } } @@ -299,6 +336,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (isInitialBootstrap) { initialBootstrapDoneRef.current = true; setIsApiReady(isApiGatewayReady()); + setIsSessionReady(isApiGatewayReady()); setIsLoading(false); } } @@ -359,14 +397,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) }); if (auth.pinVerified) { - saveSession(auth); + await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, - [saveSession] + [establishSession] ); const identifyLogin = React.useCallback(async (loginValue: string) => { @@ -394,14 +432,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) }); if (response.auth?.pinVerified) { - saveSession(response.auth); + await establishSession(response.auth); } else if (response.auth) { persistPartialAuth(response.auth); setHasStoredSession(true); } return response; }, - [saveSession] + [establishSession] ); const loginWithPassword = React.useCallback( @@ -415,14 +453,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) }); if (auth.pinVerified) { - saveSession(auth); + await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, - [saveSession] + [establishSession] ); const loginWithLdap = React.useCallback( @@ -436,14 +474,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) }); if (auth.pinVerified) { - saveSession(auth); + await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, - [saveSession] + [establishSession] ); const beginTotpLogin = React.useCallback(async (recipient: string) => { @@ -464,20 +502,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { body: JSON.stringify({ totpChallengeToken, code }) }); if (auth.pinVerified) { - saveSession(auth); + await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); } return auth; }, - [saveSession] + [establishSession] ); const applyLoginAuth = React.useCallback( - (auth: AuthTokens) => { + async (auth: AuthTokens) => { if (auth.pinVerified) { - saveSession(auth); + await establishSession(auth); } else { persistPartialAuth(auth); setHasStoredSession(true); @@ -485,7 +523,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } return auth; }, - [activatePinLock, saveSession] + [activatePinLock, establishSession] ); const completePin = React.useCallback( @@ -498,14 +536,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { window.localStorage.setItem(AUTH_SESSION_KEY, sessionId); setToken(response.accessToken); - const session = await fetchAuthSession(response.accessToken); - applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + await warmUpAndApplySession(response.accessToken); if (pathname.startsWith('/auth/')) { router.replace('/'); } }, - [activatePinLock, clearPinLock, pathname, router] + [pathname, router, warmUpAndApplySession] ); const handlePinUnlock = React.useCallback( @@ -548,22 +585,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const isBootstrapping = !isPinLocked && Boolean(hasStoredSession || token || user) && - (isLoading || !isApiReady); - - React.useEffect(() => { - if (isBootstrapping) { - setIsContentReady(false); - return; - } - let cancelled = false; - const timer = window.setTimeout(() => { - if (!cancelled) setIsContentReady(true); - }, 650); - return () => { - cancelled = true; - window.clearTimeout(timer); - }; - }, [isBootstrapping]); + (isLoading || !isApiReady || !isSessionReady); return ( - {isBootstrapping || !isContentReady ? : children} + {isBootstrapping ? : children} ); diff --git a/apps/frontend/components/id/public-settings-provider.tsx b/apps/frontend/components/id/public-settings-provider.tsx index 3165df0..1b12ada 100644 --- a/apps/frontend/components/id/public-settings-provider.tsx +++ b/apps/frontend/components/id/public-settings-provider.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { apiFetch } from '@/lib/api'; +import { apiFetch, ensureApiGatewayReady } from '@/lib/api'; interface PublicSettingsContextValue { projectName: string; @@ -27,6 +27,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode const refreshPublicSettings = useCallback(async () => { try { + await ensureApiGatewayReady(); const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public'); const map = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value])); setSettings(map); diff --git a/apps/frontend/hooks/use-require-auth.ts b/apps/frontend/hooks/use-require-auth.ts index 3dade7a..3c5defb 100644 --- a/apps/frontend/hooks/use-require-auth.ts +++ b/apps/frontend/hooks/use-require-auth.ts @@ -7,20 +7,21 @@ import { useAuth } from '@/components/id/auth-provider'; export function useRequireAuth() { const router = useRouter(); const pathname = usePathname(); - const { user, isLoading, isApiReady, isPinLocked, hasStoredSession } = useAuth(); + const { user, isLoading, isApiReady, isSessionReady, isPinLocked, hasStoredSession } = useAuth(); useEffect(() => { - if (isLoading || !isApiReady) return; + if (isLoading || !isApiReady || !isSessionReady) return; if (user || isPinLocked || hasStoredSession) return; if (pathname.startsWith('/auth/')) return; router.replace('/auth/login'); - }, [hasStoredSession, isApiReady, isLoading, isPinLocked, pathname, router, user]); + }, [hasStoredSession, isApiReady, isLoading, isPinLocked, isSessionReady, pathname, router, user]); return { user, isLoading, isPinLocked, isApiReady, - isReady: !isLoading && isApiReady && Boolean(user || isPinLocked || hasStoredSession) + isSessionReady, + isReady: !isLoading && isApiReady && isSessionReady && Boolean(user || isPinLocked || hasStoredSession) }; } diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 0d7ca5e..bcf8ae1 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -677,13 +677,15 @@ export function buildSystemSettingPayload(setting: Pick { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -703,6 +705,9 @@ export function resetApiGatewayWarmup() { export function invalidateApiGatewayReady() { gatewayReadyResolved = false; gatewayReadyAt = 0; + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT)); + } } export function isApiGatewayReady() { @@ -728,6 +733,37 @@ export function subscribeApiReady(listener: () => void): () => void { return () => window.removeEventListener(API_READY_EVENT, listener); } +export function subscribeApiNotReady(listener: () => void): () => void { + if (typeof window === 'undefined') return () => undefined; + window.addEventListener(API_NOT_READY_EVENT, listener); + return () => window.removeEventListener(API_NOT_READY_EVENT, listener); +} + +/** Несколько подряд успешных /health через nginx — защита от «один OK, потом 502». */ +export async function waitForStableGateway(stableProbes = GATEWAY_STABLE_PROBES): Promise { + if (typeof window === 'undefined') return; + + gatewayReadyResolved = false; + gatewayReadyAt = 0; + + let streak = 0; + for (let attempt = 0; attempt < 40 && streak < stableProbes; attempt += 1) { + if (await probeGatewayAlive()) { + streak += 1; + if (streak >= stableProbes) { + markApiGatewayReady(); + return; + } + await sleep(400); + } else { + streak = 0; + await sleep(Math.min(500 + attempt * 220, 2200)); + } + } + + throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE'); +} + async function acquireApiSlot(): Promise { const inBurst = gatewayReadyAt > 0 && Date.now() - gatewayReadyAt < API_BURST_WINDOW_MS; const limit = inBurst ? API_BURST_MAX_CONCURRENT : API_MAX_CONCURRENT; @@ -807,14 +843,24 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise