feat(web): базовый клиент Фазы 1 — вход, серверы, комнаты, настройки

Клиент React 19 + TanStack Query + Zustand:

- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
  и учётом `registration_enabled`, онбординг первого входа с возможностью
  пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
  `/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
  панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
  со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
  локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
  экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
  CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
  MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.

Серверные правки под клиент:

- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
  нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
  рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
This commit is contained in:
2026-09-19 22:03:46 +03:00
parent d6ca8e420f
commit 9c001fedfe
28 changed files with 1827 additions and 217 deletions
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
apiError,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
} from './helpers';
const user = makeUser();
const sessions = [
{
id: 's-1',
user_agent: 'Firefox on Linux',
ip: '10.0.0.2',
created_at: '2026-09-01T10:00:00Z',
last_seen: '2026-09-19T10:00:00Z',
current: true,
},
{
id: 's-2',
user_agent: 'Chrome on macOS',
ip: '10.0.0.3',
created_at: '2026-09-02T10:00:00Z',
last_seen: '2026-09-18T10:00:00Z',
current: false,
},
];
describe('настройки: безопасность', () => {
it('включение 2FA показывает секрет и коды восстановления', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{
match: '/api/v1/auth/2fa/setup',
method: 'POST',
response: () =>
json({
secret: 'JBSWY3DPEHPK3PXP',
otpauth_url: 'otpauth://totp/glchat:alice?secret=X',
// QR-код рисует сервер и отдаёт data-URI: внешние сервисы не нужны.
qr_png: 'data:image/png;base64,iVBORw0KGgo=',
}),
},
{
match: '/api/v1/auth/2fa/enable',
method: 'POST',
response: () => json({ recovery_codes: ['aaaa-bbbb', 'cccc-dddd'] }),
},
]);
renderApp('/settings/security');
const visitor = userEvent.setup();
await visitor.click(await screen.findByRole('button', { name: 'Включить 2FA' }));
expect(await screen.findByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP');
expect(screen.getByAltText('QR-код для настройки 2FA')).toBeVisible();
await visitor.type(screen.getByLabelText('Код из приложения'), '123456');
await visitor.click(screen.getByRole('button', { name: 'Включить' }));
const codes = await screen.findByTestId('recovery-codes');
expect(codes).toHaveTextContent('aaaa-bbbb');
expect(codes).toHaveTextContent('cccc-dddd');
expect(screen.getByText('2FA включена. Сохраните коды восстановления.')).toBeVisible();
expect(findRequest(fetchMock, { url: '/auth/2fa/enable', method: 'POST' })?.body).toEqual({
code: '123456',
});
});
it('logout-all вызывается по кнопке и уводит на /login', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
]);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { router } = renderApp('/settings/security');
const visitor = userEvent.setup();
expect(await screen.findByTestId('sessions-list')).toHaveTextContent('Firefox on Linux');
await visitor.click(screen.getByRole('button', { name: 'Выйти на всех устройствах' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/login');
});
expect(confirm).toHaveBeenCalled();
expect(
recordedRequests(fetchMock).some(
(request) => request.url.includes('/auth/logout-all') && request.method === 'POST',
),
).toBe(true);
});
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
let passwordAttempts = 0;
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
{
match: '/api/v1/users/@me/password',
method: 'POST',
response: () => {
passwordAttempts += 1;
return passwordAttempts === 1
? apiError('auth.step_up_required', 403)
: json({ ok: true });
},
},
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
]);
renderApp('/settings/security');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Текущий пароль'), 'old-password-1');
await visitor.type(screen.getByLabelText('Новый пароль'), 'new-password-1');
await visitor.type(screen.getByLabelText('Повторите новый пароль'), 'new-password-1');
await visitor.click(screen.getByRole('button', { name: 'Сменить пароль' }));
// Сервер ответил auth.step_up_required — появилась форма подтверждения.
expect(await screen.findByText('Подтвердите личность')).toBeVisible();
await visitor.type(screen.getByLabelText('Ваш пароль'), 'old-password-1');
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
expect(await screen.findByText('Пароль изменён.')).toBeVisible();
expect(
recordedRequests(fetchMock).filter(
(request) => request.url.includes('/users/@me/password') && request.method === 'POST',
),
).toHaveLength(2);
});
it('профиль сохраняется через PATCH /users/@me', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, display_name: 'Алиса' } }),
},
]);
renderApp('/settings/profile');
const visitor = userEvent.setup();
const displayName = await screen.findByLabelText('Отображаемое имя');
await visitor.clear(displayName);
await visitor.type(displayName, 'Алиса');
await visitor.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
const patchCall = findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' });
expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' });
});
it('вкладка «Инстанс» скрыта от не-администратора', async () => {
installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]);
renderApp('/settings/profile');
expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible();
expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull();
});
it('администратор видит данные инстанса', async () => {
const admin = makeUser({ is_instance_admin: true });
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
{
match: '/api/v1/instance/settings',
response: () => json({ settings: { motd: 'привет' } }),
},
{
match: '/api/v1/instance/guilds',
response: () =>
json({
guilds: [{ id: 'g-1', name: 'Main', member_count: 3, owner_id: 'u', is_main: true }],
}),
},
{
match: '/api/v1/instance/users',
response: () =>
json({
users: [
{
id: 'u-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: true,
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
{
match: '/api/v1/instance/audit',
response: () =>
json({
entries: [
{
id: 'a-1',
actor_id: 'u-1',
action: 'guild.create',
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
]);
renderApp('/settings/instance');
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create');
expect(screen.getByText('motd')).toBeVisible();
});
});