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
+229
View File
@@ -0,0 +1,229 @@
import { userStatuses, type Channel, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
/**
* Разбор payload'ов событий шлюза (AGENT.md §8.3).
*
* Все функции возвращают `null`, если кадр не соответствует контракту:
* неизвестные и лишние поля не ломают диспетчер, а событие просто
* игнорируется.
*/
export interface UserUpdateEvent {
user: GatewayUser;
}
export interface GuildUpdateEvent {
guild_id: string;
name?: string;
description?: string;
}
export interface GuildDeleteEvent {
guild_id: string;
}
export interface GuildCreateEvent {
guild_id: string;
}
export interface ChannelDeleteEvent {
channel_id: string;
guild_id: string;
}
export interface MemberUpdateEvent {
guild_id: string;
user_id: string;
role_ids?: string[];
role_id?: string;
role_added?: boolean;
}
export interface MemberRemoveEvent {
guild_id: string;
user_id: string;
}
export interface RoleEvent {
guild_id: string;
role_id: string;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function asStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: undefined;
}
/** Пользователь в объёме `USER_UPDATE` (совместим со снапшотом READY). */
export function parseGatewayUserPayload(value: unknown): GatewayUser | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const username = asString(record['username']);
if (id === undefined || username === undefined) {
return null;
}
const user: GatewayUser = {
id,
username,
display_name: asString(record['display_name']) ?? username,
is_instance_admin: record['is_instance_admin'] === true,
badges: asStringArray(record['badges']) ?? [],
};
const avatar = asString(record['avatar_file_id']);
const status = asString(record['status']);
const customStatus = asString(record['custom_status']);
const customEmoji = asString(record['custom_status_emoji']);
if (avatar !== undefined) {
user.avatar_file_id = avatar;
}
if (userStatuses.includes(status as UserStatus)) {
user.status = status as UserStatus;
}
if (customStatus !== undefined) {
user.custom_status = customStatus;
}
if (customEmoji !== undefined) {
user.custom_status_emoji = customEmoji;
}
return user;
}
/** Деструктурирует `d` у событий, где payload вложен в `user`. */
export function parseUserUpdate(value: unknown): UserUpdateEvent | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const user = parseGatewayUserPayload(record['user'] ?? value);
return user === null ? null : { user };
}
export function parseGuildUpdate(value: unknown): GuildUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
if (record === null || guildId === undefined) {
return null;
}
const event: GuildUpdateEvent = { guild_id: guildId };
const name = asString(record['name']);
const description = asString(record['description']);
if (name !== undefined) {
event.name = name;
}
if (description !== undefined) {
event.description = description;
}
return event;
}
export function parseGuildId(value: unknown): string | null {
const record = asRecord(value);
return asString(record?.['guild_id']) ?? null;
}
export function parseChannelDelete(value: unknown): ChannelDeleteEvent | null {
const record = asRecord(value);
const channelId = asString(record?.['channel_id']);
const guildId = asString(record?.['guild_id']);
if (channelId === undefined || guildId === undefined) {
return null;
}
return { channel_id: channelId, guild_id: guildId };
}
export function parseMemberUpdate(value: unknown): MemberUpdateEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
const userId = asString(record?.['user_id']);
if (record === null || guildId === undefined || userId === undefined) {
return null;
}
const event: MemberUpdateEvent = { guild_id: guildId, user_id: userId };
const roleIds = asStringArray(record['role_ids']);
const roleId = asString(record['role_id']);
if (roleIds !== undefined) {
event.role_ids = roleIds;
}
if (roleId !== undefined) {
event.role_id = roleId;
}
if (typeof record['role_added'] === 'boolean') {
event.role_added = record['role_added'];
}
return event;
}
export function parseMemberRemove(value: unknown): MemberRemoveEvent | null {
const event = parseMemberUpdate(value);
return event === null ? null : { guild_id: event.guild_id, user_id: event.user_id };
}
export function parseRoleEvent(value: unknown): RoleEvent | null {
const record = asRecord(value);
const guildId = asString(record?.['guild_id']);
const roleId = asString(record?.['role_id']);
if (record === null || guildId === undefined || roleId === undefined) {
return null;
}
return { guild_id: guildId, role_id: roleId };
}
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
export function parseChannelPayload(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
const type = asString(record['type']);
if (id === undefined || name === undefined) {
return null;
}
if (type !== 'text' && type !== 'voice' && type !== 'category') {
return null;
}
const channel: Channel = {
id,
name,
type,
position: typeof record['position'] === 'number' ? record['position'] : 0,
};
const guildId = asString(record['guild_id']);
const parentId = asString(record['parent_id']);
if (guildId !== undefined) {
channel.guild_id = guildId;
}
if (parentId !== undefined) {
channel.parent_id = parentId;
}
if (typeof record['user_limit'] === 'number') {
channel.user_limit = record['user_limit'];
}
if (typeof record['slowmode_seconds'] === 'number') {
channel.slowmode_seconds = record['slowmode_seconds'];
}
if (typeof record['can_send'] === 'boolean') {
channel.can_send = record['can_send'];
}
if (typeof record['can_connect'] === 'boolean') {
channel.can_connect = record['can_connect'];
}
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
return channel;
}