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 }) }]);