From 751ecc6d451bbc97e5b8953c09241b6d53dcee9c Mon Sep 17 00:00:00 2001 From: grendervill Date: Tue, 22 Sep 2026 21:52:16 +0300 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B9=20=D1=83=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BD=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер отзывал сессии кадром {"op":4,"reason":"…","resumable":false} и закрывал соединение, но устройство с открытым сервером (/app/<сервер>/<комната>) оставалось в приложении: уход на /login зависел только от 401 на запросе профиля, а перечитывание профиля в этой ветке не срабатывало (пункт 12 матрицы 11.6, проверено перехватом WS на стенде). Теперь признак invalidated в сторе шлюза читает AuthGuard и сразу уводит на экран входа — в любом состоянии приложения, не дожидаясь ответа REST. Признак снимается при успешном входе, иначе форма входа зацикливалась бы. Тесты: «отзыв сессии на открытом сервере уводит на /login»; живая проверка build/verify-session-invalidation.mjs дополнена сценарием с открытым сервером. --- web/src/components/AuthGuard.tsx | 9 +++++++++ web/src/pages/LoginPage.tsx | 5 ++++- web/tests/guard.test.tsx | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/web/src/components/AuthGuard.tsx b/web/src/components/AuthGuard.tsx index 33a6ae9..df87683 100644 --- a/web/src/components/AuthGuard.tsx +++ b/web/src/components/AuthGuard.tsx @@ -3,6 +3,7 @@ import { Navigate, Outlet, useLocation } from 'react-router'; import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; import { useCurrentUser } from '@/lib/hooks'; import { isUnauthorized } from '@/lib/http'; +import { useGatewayStore } from '@/stores/gateway'; interface AuthGuardProps { /** @@ -19,6 +20,14 @@ interface AuthGuardProps { export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps) { const location = useLocation(); const currentUser = useCurrentUser(); + // Сервер отозвал сессии (logout-all, смена пароля, бан): шлюз уже сообщил об + // этом, поэтому на экран входа уходим сразу — не дожидаясь ответа 401 на + // запрос профиля и независимо от того, открыт ли сервер (AGENT.md 11.6). + const invalidated = useGatewayStore((state) => state.invalidated); + + if (invalidated) { + return ; + } if (currentUser.isPending) { return ( diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 5640205..5b3d0ce 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -10,6 +10,7 @@ import { Button, Card } from '@/components/ui/primitives'; import { getQueryClient } from '@/lib/queryClient'; import { useInstance } from '@/lib/hooks'; import { errorCode } from '@/lib/format'; +import { useGatewayStore } from '@/stores/gateway'; interface LocationState { from?: string; @@ -45,7 +46,9 @@ export default function LoginPage() { return login(code === '' ? { email, password } : { email, password, totp_code: code }); }, onSuccess: async () => { - // Профиль перечитываем заново: cookie уже выставлена сервером. + // Профиль перечитываем заново: cookie уже выставлена сервером. Признак + // отозванной сессии снимаем — иначе AuthGuard увёл бы обратно на /login. + useGatewayStore.getState().reset(); await getQueryClient().invalidateQueries(); void navigate(from === '/login' ? '/app' : from, { replace: true }); }, diff --git a/web/tests/guard.test.tsx b/web/tests/guard.test.tsx index b6c8170..8a13ad3 100644 --- a/web/tests/guard.test.tsx +++ b/web/tests/guard.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; +import { dispatchGatewayEvent } from '@/stores/gateway'; import { apiError, installFetch, installFailingFetch, json, makeUser, renderApp } from './helpers'; describe('защита маршрутов', () => { @@ -17,6 +18,27 @@ describe('защита маршрутов', () => { expect(await screen.findByLabelText('Почта')).toBeVisible(); }); + it('отзыв сессии на открытом сервере уводит на /login', async () => { + const user = makeUser(); + installFetch([ + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + ]); + const { router } = renderApp('/app/g-1/c-1'); + await waitFor(() => { + expect(screen.getByTestId('guild-rail')).toBeVisible(); + }); + + // Сервер отозвал сессии (logout-all, смена пароля): приходит INVALID_SESSION, + // и уход на экран входа не зависит от ответа запроса профиля (AGENT.md 11.6). + dispatchGatewayEvent({ op: 0, t: 'SESSION_INVALIDATED', s: 1, d: { reason: 'logout_all' } }); + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/login'); + }); + expect(await screen.findByLabelText('Почта')).toBeVisible(); + }); + it('пользователя с onboarding_completed=false отправляет на /onboarding', async () => { const user = makeUser({ onboarding_completed: false }); installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);