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
+63 -16
View File
@@ -59,6 +59,16 @@ class FakeSocket {
}
}
const connections: GatewayConnection[] = [];
afterEach(() => {
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
for (const connection of connections.splice(0)) {
connection.close();
}
FakeSocket.instances = [];
});
function makeConnection(handlers: {
onDispatch?: (event: GatewayDispatch) => void;
onStatus?: (status: GatewayStatus) => void;
@@ -79,16 +89,6 @@ function makeConnection(handlers: {
return connection;
}
const connections: GatewayConnection[] = [];
afterEach(() => {
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
for (const connection of connections.splice(0)) {
connection.close();
}
FakeSocket.instances = [];
});
describe('gateway client', () => {
it('answers HELLO with IDENTIFY and an empty token', () => {
const connection = makeConnection({});
@@ -112,9 +112,14 @@ describe('gateway client', () => {
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: makeReadySnapshot([
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
]) });
socket?.emit({
op: GatewayOp.DISPATCH,
t: 'READY',
s: 1,
d: makeReadySnapshot([
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
]),
});
expect(events).toHaveLength(1);
expect(events[0]?.t).toBe('READY');
@@ -165,7 +170,7 @@ describe('gateway client', () => {
first?.closeFromServer();
expect(connection.getStatus()).toBe('reconnecting');
await new Promise((resolve) => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 100));
const second = FakeSocket.instances[1];
expect(second).toBeDefined();
second?.open();
@@ -176,16 +181,58 @@ describe('gateway client', () => {
connection.close();
});
it('reports INVALID_SESSION to the handler', () => {
it('INVALID_SESSION с resumable=false разлогинивает', () => {
let invalidated = 0;
const connection = makeConnection({ onInvalidSession: () => (invalidated += 1) });
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.INVALID_SESSION, d: false });
socket?.emit({
op: GatewayOp.INVALID_SESSION,
d: { reason: 'invalid token', resumable: false },
});
expect(invalidated).toBe(1);
expect(connection.getStatus()).toBe('disconnected');
connection.close();
});
it('INVALID_SESSION с resumable=true не разлогинивает и ждёт полный READY', () => {
const events: GatewayDispatch[] = [];
let invalidated = 0;
const connection = makeConnection({
onDispatch: (event) => events.push(event),
onInvalidSession: () => (invalidated += 1),
});
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 3, d: makeReadySnapshot([]) });
socket?.emit({
op: GatewayOp.INVALID_SESSION,
d: { reason: 'resume missed', resumable: true },
});
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 4, d: makeReadySnapshot([]) });
expect(invalidated).toBe(0);
expect(events.map((event) => event.t)).toEqual(['READY', 'READY']);
expect(connection.getLastSeq()).toBe(4);
expect(connection.getStatus()).toBe('connected');
connection.close();
});
it('op 7 (RECONNECT) переподключает сокет', async () => {
const connection = makeConnection({});
const first = FakeSocket.instances[0];
first?.open();
first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 5, d: makeReadySnapshot([]) });
first?.emit({ op: GatewayOp.RECONNECT, d: null });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(FakeSocket.instances.length).toBe(2);
expect(connection.getStatus()).toBe('reconnecting');
connection.close();
});
});