diff --git a/apps/api-gateway/src/controllers/fedcm.controller.ts b/apps/api-gateway/src/controllers/fedcm.controller.ts index 669ba12..f22a013 100644 --- a/apps/api-gateway/src/controllers/fedcm.controller.ts +++ b/apps/api-gateway/src/controllers/fedcm.controller.ts @@ -76,6 +76,15 @@ export class FedcmController { applyFedcmPreflightHeaders(res, origin); } + @Options('discover.json') + @HttpCode(204) + discoverPreflight(@Res({ passthrough: true }) res: Response) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept'); + res.setHeader('Access-Control-Max-Age', '86400'); + } + @Get('config.json') @ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' }) async config(@Req() req: Request, @Res({ passthrough: true }) res: Response) { diff --git a/apps/frontend/app/data/page.tsx b/apps/frontend/app/data/page.tsx index 1d5878a..86feefb 100644 --- a/apps/frontend/app/data/page.tsx +++ b/apps/frontend/app/data/page.tsx @@ -143,7 +143,7 @@ export default function DataPage() { const status = await fetchAccountDeletionStatus(userId, token); setDeletionStatus(status); } catch { - setDeletionStatus(null); + // Фоновый сбой gateway не должен сбрасывать уже показанный статус удаления. } }, [isPinLocked, token, userId]); diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index f1e18c1..cf51c9b 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -19,23 +19,24 @@ export default function HomePage() { const router = useRouter(); const { user, token, isPinLocked } = useAuth(); const { isReady } = useRequireAuth(); + const userId = user?.id; const contactLine = [user?.phone, user?.username ?? user?.email].filter(Boolean).join(' · '); const [documents, setDocuments] = useState([]); const [activeDocumentType, setActiveDocumentType] = useState(null); const loadDocuments = useCallback(async () => { - if (!user || !token || isPinLocked) return; + if (!userId || !token || isPinLocked) return; try { - const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token); + const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${userId}`, {}, token); setDocuments(response.documents ?? []); } catch { - setDocuments([]); + // Фоновая ошибка не должна очищать уже показанные документы. } - }, [isPinLocked, token, user]); + }, [isPinLocked, token, userId]); useEffect(() => { - if (isReady && user && !isPinLocked) void loadDocuments(); - }, [isPinLocked, isReady, loadDocuments, user]); + if (isReady && userId && !isPinLocked) void loadDocuments(); + }, [isPinLocked, isReady, loadDocuments, userId]); const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]); diff --git a/apps/frontend/app/security/page.tsx b/apps/frontend/app/security/page.tsx index 1249081..648ae6b 100644 --- a/apps/frontend/app/security/page.tsx +++ b/apps/frontend/app/security/page.tsx @@ -67,6 +67,7 @@ export default function SecurityPage() { const { user, token, refreshProfile } = useAuth(); const { isReady } = useRequireAuth(); const { showToast } = useToast(); + const userId = user?.id; const [devices, setDevices] = useState([]); const [sessions, setSessions] = useState([]); const [history, setHistory] = useState([]); @@ -92,14 +93,14 @@ export default function SecurityPage() { const hasEmailContact = Boolean(user?.email || user?.backupEmail); const loadSecurity = useCallback(async () => { - if (!user || !token) return; + if (!userId || !token) return; setIsSecurityLoading(true); try { const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([ - apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token), - apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${user.id}/sessions`, {}, token), - apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token), - apiFetch(`/security/users/${user.id}/totp/status`, {}, token) + apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${userId}/devices`, {}, token), + apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${userId}/sessions`, {}, token), + apiFetch<{ events: SignInEvent[] }>(`/security/users/${userId}/sign-in-history`, {}, token), + apiFetch(`/security/users/${userId}/totp/status`, {}, token) ]); setDevices(devicesResponse.devices ?? []); setSessions(sessionsResponse.sessions ?? []); @@ -107,16 +108,17 @@ export default function SecurityPage() { setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY)); setTotpEnabled(Boolean(totpResponse.isEnabled)); } catch (error) { - showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность'); + const message = getApiErrorMessage(error, 'Не удалось загрузить безопасность'); + if (message) showToast(message); } finally { setIsSecurityLoading(false); } - }, [showToast, token, user]); + }, [showToast, token, userId]); useEffect(() => { - if (!isReady || !user) return; + if (!isReady || !userId) return; void loadSecurity(); - }, [isReady, loadSecurity, user]); + }, [isReady, loadSecurity, userId]); async function revokeSession(sessionId: string) { if (!user || !token) return; diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 509a53c..f2b223d 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { apiFetch, ApiError, + AUTH_SESSION_INVALID_EVENT, AUTH_REFRESH_KEY, AUTH_SESSION_KEY, AUTH_TOKEN_KEY, @@ -106,14 +107,12 @@ function applySessionState( } function readInitialAuthState() { - if (typeof window === 'undefined') { - return { token: null as string | null, hasStoredSession: false }; - } - const token = window.localStorage.getItem(AUTH_TOKEN_KEY); - const refresh = window.localStorage.getItem(AUTH_REFRESH_KEY); + // Первичный render должен совпадать на сервере и в браузере. Токены из + // localStorage читаются уже в refreshProfile(), иначе авторизованная вкладка + // получает hydration mismatch (React #418) ещё до запуска эффектов. return { - token, - hasStoredSession: Boolean(refresh || token) + token: null as string | null, + hasStoredSession: false }; } @@ -274,6 +273,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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; @@ -291,7 +300,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const task: Promise = (async () => { const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY); const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); - setHasStoredSession(Boolean(refreshToken)); + setHasStoredSession(Boolean(currentToken || refreshToken)); if (!currentToken && !refreshToken) { window.localStorage.removeItem(AUTH_USER_CACHE_KEY); diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index c44a792..5c7b6ae 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -598,7 +598,10 @@ async function trySilentTokenRefresh(): Promise { } } return refreshed; - } catch { + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + notifyAuthSessionInvalid(); + } return null; } } @@ -685,6 +688,8 @@ const API_BURST_MAX_CONCURRENT = 2; const API_BURST_WINDOW_MS = 5000; const API_READY_EVENT = 'idp-api-ready'; const API_NOT_READY_EVENT = 'idp-api-not-ready'; +export const AUTH_SESSION_INVALID_EVENT = 'idp-auth-session-invalid'; +const GATEWAY_CIRCUIT_COOLDOWN_MS = 8_000; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -694,6 +699,7 @@ let gatewayReadyResolved = false; let gatewayReadyPromise: Promise | null = null; let stableGatewayPromise: Promise | null = null; let gatewayReadyAt = 0; +let gatewayCircuitOpenUntil = 0; let activeApiRequests = 0; const apiRequestWaiters: Array<() => void> = []; @@ -701,6 +707,7 @@ export function resetApiGatewayWarmup() { gatewayReadyResolved = false; gatewayReadyPromise = null; stableGatewayPromise = null; + gatewayCircuitOpenUntil = 0; } export function invalidateApiGatewayReady() { @@ -712,6 +719,29 @@ export function invalidateApiGatewayReady() { } } +function openGatewayCircuit(): void { + gatewayReadyResolved = false; + gatewayReadyAt = 0; + stableGatewayPromise = null; + gatewayCircuitOpenUntil = Date.now() + GATEWAY_CIRCUIT_COOLDOWN_MS; + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(API_NOT_READY_EVENT)); + } +} + +function assertGatewayCircuitClosed(): void { + if (typeof window === 'undefined') return; + if (Date.now() < gatewayCircuitOpenUntil) { + throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE'); + } + gatewayCircuitOpenUntil = 0; +} + +function notifyAuthSessionInvalid(): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(AUTH_SESSION_INVALID_EVENT)); +} + export function isApiGatewayReady() { return gatewayReadyResolved; } @@ -720,6 +750,7 @@ export function markApiGatewayReady() { if (gatewayReadyResolved) return; gatewayReadyResolved = true; gatewayReadyAt = Date.now(); + gatewayCircuitOpenUntil = 0; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(API_READY_EVENT)); } @@ -816,6 +847,7 @@ export async function ensureApiGatewayReady(force = false): Promise { gatewayReadyResolved = false; gatewayReadyAt = 0; stableGatewayPromise = null; + gatewayCircuitOpenUntil = 0; } const probes = force ? 2 : 1; @@ -835,6 +867,7 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise(path: string, options: RequestInit = {}, token }; let response: Response; + let apiSlotReleased = false; + const releaseCurrentApiSlot = () => { + if (apiSlotReleased) return; + apiSlotReleased = true; + releaseApiSlot(); + }; + try { try { response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { @@ -892,10 +940,15 @@ export async function apiFetch(path: string, options: RequestInit = {}, token throw error; } + if (resolvedToken && error.status === 401 && error.code !== 'TOKEN_EXPIRED') { + notifyAuthSessionInvalid(); + throw error; + } + if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { const refreshed = await trySilentTokenRefresh(); if (refreshed?.accessToken) { - releaseApiSlot(); + releaseCurrentApiSlot(); return apiFetch(path, options, refreshed.accessToken, false); } if (refreshed?.requiresPin) { @@ -903,13 +956,17 @@ export async function apiFetch(path: string, options: RequestInit = {}, token } } + if (resolvedToken && error.status === 401) { + notifyAuthSessionInvalid(); + } + throw error; } markApiGatewayReady(); return response.json() as Promise; } finally { - releaseApiSlot(); + releaseCurrentApiSlot(); } } diff --git a/install.sh b/install.sh index 5f6316a..fc8f8ff 100644 --- a/install.sh +++ b/install.sh @@ -2336,12 +2336,13 @@ write_nginx_site_api() { local domain="$1" local ssl_type="${2:-none}" local ws_on_api="${3:-true}" - local conf ws_block http_preamble ssl_block api_upstream api_location + local conf ws_block http_preamble ssl_block api_upstream api_location server_dns conf="$(nginx_conf_target api)" ws_block="$(build_ws_block "$ws_on_api")" http_preamble="$(nginx_http_server_preamble "$domain" "$ssl_type")" api_upstream="$(build_nginx_api_upstream_block)" api_location="$(build_nginx_api_location_block)" + server_dns="$(nginx_docker_server_dns_block)" if [[ "$ssl_type" == "none" ]]; then nginx_write_conf "$conf" </dev/null; then fail "Конфиг ${conf} содержит proxy_pass \$request_uri — нужен ./install.sh --fix-all (502 на / без resolver)" fi + if grep -q 'proxy_pass \$' "$conf" 2>/dev/null && ! grep -q 'resolver 127\.0\.0\.11' "$conf" 2>/dev/null; then + fail "Конфиг ${conf} использует variable proxy_pass без Docker resolver — будет 502 на api.idpmvk.lpr. Выполните ./install.sh --fix-all" + fi ok "Конфиг API-домена: proxy_pass → api-gateway:3000" } @@ -2743,6 +2749,9 @@ validate_all_docker_nginx_proxy_pass() { if grep -q 'proxy_pass.*\$request_uri' "$conf" 2>/dev/null; then fail "Конфиг $(basename "$conf") содержит proxy_pass \$request_uri — 502 «no resolver defined» на /. Выполните ./install.sh --fix-all" fi + if grep -q 'proxy_pass \$' "$conf" 2>/dev/null && ! grep -q 'resolver 127\.0\.0\.11' "$conf" 2>/dev/null; then + fail "Конфиг $(basename "$conf") использует variable proxy_pass без resolver 127.0.0.11 — после recreate контейнеров будут 502" + fi done shopt -u nullglob }