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
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest';
import { enableTotp, login, logoutAll, register, setupTotp, stepUp } from '@/api/auth';
import { createChannel, fetchChannels, joinGuild, leaveGuild } from '@/api/guilds';
import { fetchInstance, fetchAudit } from '@/api/instance';
import { fetchMyGuilds, updateProfile } from '@/api/users';
import { ApiError } from '@/api/client';
import { findRequest, installFetch, json, recordedRequests } from './helpers';
function lastCall(fetchMock: ReturnType<typeof installFetch>) {
const request = recordedRequests(fetchMock).at(-1);
if (request === undefined) {
throw new Error('fetch не вызывался');
}
return request;
}
describe('api-модули Фазы 1', () => {
it('register/login отправляют POST с телом и читают поле user', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/auth/register',
method: 'POST',
response: () => json({ user: { id: 'u' } }),
},
]);
await expect(
register({
username: 'alice',
display_name: 'Alice',
email: 'a@b.co',
password: 'password12',
locale: 'ru',
}),
).resolves.toEqual({ user: { id: 'u' } });
expect(findRequest(fetchMock, { url: '/auth/register', method: 'POST' })?.method).toBe('POST');
});
it('login отправляет POST с телом', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
]);
await login({ email: 'a@b.co', password: 'password12' });
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
email: 'a@b.co',
password: 'password12',
});
});
it('login прокидывает код 2FA как totp_code', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user: { id: 'u' } }) },
]);
await login({ email: 'a@b.co', password: 'password12', totp_code: '123456' });
expect(lastCall(fetchMock).body).toEqual({
email: 'a@b.co',
password: 'password12',
totp_code: '123456',
});
});
it('setup/enable 2FA и step-up используют нужные пути', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/auth/2fa/setup',
method: 'POST',
response: () => json({ secret: 'SECRET', otpauth_url: 'otpauth://x' }),
},
{
match: '/api/v1/auth/2fa/enable',
method: 'POST',
response: () => json({ recovery_codes: ['one', 'two'] }),
},
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
{ match: '/api/v1/auth/logout-all', method: 'POST', response: () => json({ ok: true }) },
]);
await expect(setupTotp()).resolves.toEqual({ secret: 'SECRET', otpauth_url: 'otpauth://x' });
await expect(enableTotp('123456')).resolves.toEqual(['one', 'two']);
await stepUp('password12', '654321');
expect(lastCall(fetchMock).body).toEqual({
password: 'password12',
totp_code: '654321',
});
await expect(logoutAll()).resolves.toEqual({ ok: true });
});
it('PATCH профиля уходит на /users/@me и возвращает user', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { id: 'u', display_name: 'Новое' } }),
},
]);
await expect(updateProfile({ display_name: 'Новое' })).resolves.toMatchObject({
display_name: 'Новое',
});
expect(lastCall(fetchMock).method).toBe('PATCH');
});
it('GET /users/@me/guilds разворачивает конверт {guilds}', async () => {
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [{ id: 'g-1' }] }) },
]);
await expect(fetchMyGuilds()).resolves.toEqual([{ id: 'g-1' }]);
});
it('каналы и участие в сервере используют id в пути', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [{ id: 'c-1' }] }) },
{ match: '/api/v1/guilds/g-1/join', method: 'POST', response: () => json({ ok: true }) },
{ match: '/api/v1/guilds/g-1/leave', method: 'POST', response: () => json({ ok: true }) },
]);
await expect(fetchChannels('g-1')).resolves.toEqual([{ id: 'c-1' }]);
await expect(joinGuild('g-1')).resolves.toEqual({ ok: true });
await expect(leaveGuild('g-1')).resolves.toEqual({ ok: true });
expect(lastCall(fetchMock).url).toContain('/api/v1/guilds/g-1/leave');
});
it('createChannel отправляет type и parent_id', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/guilds/g-1/channels',
method: 'POST',
response: () => json({ channel: { id: 'c-9' } }),
},
]);
await createChannel('g-1', { name: 'общий', type: 'text', parent_id: 'cat-1' });
expect(lastCall(fetchMock).body).toEqual({
name: 'общий',
type: 'text',
parent_id: 'cat-1',
});
});
it('instance: конверты {instance}, {settings}, {entries} и лимит аудита', async () => {
const fetchMock = installFetch([
{ match: '/api/v1/instance/settings', response: () => json({ settings: { motd: 'x' } }) },
{ match: '/api/v1/instance/audit', response: () => json({ entries: [{ id: 'a-1' }] }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat' } }) },
]);
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
await expect(fetchAudit(50)).resolves.toEqual([{ id: 'a-1' }]);
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
'/api/v1/instance/audit?limit=50',
);
});
it('пробрасывает коды ошибок контракта (в том числе auth.guild_limit_reached)', async () => {
installFetch([
{
match: '/api/v1/guilds',
method: 'POST',
response: () =>
json({ error: { code: 'auth.guild_limit_reached', message: 'limit' } }, 403),
},
]);
const error = await import('@/api/guilds')
.then(({ createGuild }) => createGuild('Много'))
.catch((reason: unknown) => reason);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).code).toBe('auth.guild_limit_reached');
expect((error as ApiError).status).toBe(403);
});
});