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:
+63
-16
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user