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:
+10
-2
@@ -54,8 +54,16 @@ export function stepUp(password: string, totpCode?: string): Promise<{ ok: true
|
||||
return request<{ ok: true }>('/auth/step-up', { method: 'POST', body });
|
||||
}
|
||||
|
||||
export function setupTotp(): Promise<{ secret: string; otpauth_url: string }> {
|
||||
return request<{ secret: string; otpauth_url: string }>('/auth/2fa/setup', { method: 'POST' });
|
||||
/** Ответ настройки 2FA: секрет, otpauth-ссылка и QR-код (data-URI с сервера). */
|
||||
export interface TotpSetup {
|
||||
secret: string;
|
||||
otpauth_url: string;
|
||||
/** QR рисует сервер: внешние сервисы генерации QR не используются. */
|
||||
qr_png?: string;
|
||||
}
|
||||
|
||||
export function setupTotp(): Promise<TotpSetup> {
|
||||
return request<TotpSetup>('/auth/2fa/setup', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function enableTotp(code: string): Promise<string[]> {
|
||||
|
||||
+17
-55
@@ -1,3 +1,4 @@
|
||||
import { parseChannelPayload } from './gatewayEvents';
|
||||
import { describeUnknown } from '@/lib/http';
|
||||
|
||||
import type { Channel, Role, UserStatus } from './types';
|
||||
@@ -20,9 +21,10 @@ export const GatewayOp = {
|
||||
IDENTIFY: 2,
|
||||
RESUME: 3,
|
||||
INVALID_SESSION: 4,
|
||||
/** RECONNECT по AGENT.md §8.3 — opcode 7. */
|
||||
RECONNECT: 7,
|
||||
HELLO: 10,
|
||||
HEARTBEAT_ACK: 11,
|
||||
RECONNECT: 12,
|
||||
} as const;
|
||||
|
||||
export const GATEWAY_CLOSE_INVALID_SESSION = 4000;
|
||||
@@ -142,54 +144,6 @@ function asStringArray(value: unknown): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseChannel(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: asNumber(record['position']) ?? 0,
|
||||
};
|
||||
const guildId = asString(record['guild_id']);
|
||||
const parentId = asString(record['parent_id']);
|
||||
const userLimit = asNumber(record['user_limit']);
|
||||
const slowmode = asNumber(record['slowmode_seconds']);
|
||||
if (guildId !== undefined) {
|
||||
channel.guild_id = guildId;
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
channel.parent_id = parentId;
|
||||
}
|
||||
if (userLimit !== undefined) {
|
||||
channel.user_limit = userLimit;
|
||||
}
|
||||
if (slowmode !== undefined) {
|
||||
channel.slowmode_seconds = slowmode;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
function parseRole(value: unknown): Role | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) {
|
||||
@@ -265,7 +219,7 @@ function parseGuild(value: unknown): GatewayGuild | null {
|
||||
return null;
|
||||
}
|
||||
const channels = Array.isArray(record['channels'])
|
||||
? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
? record['channels'].map(parseChannelPayload).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
const roles = Array.isArray(record['roles'])
|
||||
? record['roles'].map(parseRole).filter((item): item is Role => item !== null)
|
||||
@@ -306,7 +260,9 @@ export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null {
|
||||
? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null)
|
||||
: [];
|
||||
const dmChannels = Array.isArray(record['dm_channels'])
|
||||
? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null)
|
||||
? record['dm_channels']
|
||||
.map(parseChannelPayload)
|
||||
.filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
return {
|
||||
user,
|
||||
@@ -476,7 +432,8 @@ export class GatewayConnection {
|
||||
}
|
||||
this.attempt += 1;
|
||||
const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
// Джиттер пропорционален задержке: при базовых 5 мс в тестах он не «съедает» ожидание.
|
||||
const jitter = Math.floor(Math.random() * Math.min(250, Math.max(1, backoff / 4)));
|
||||
this.setStatus('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
@@ -554,10 +511,15 @@ export class GatewayConnection {
|
||||
return;
|
||||
}
|
||||
case GatewayOp.INVALID_SESSION: {
|
||||
const resumable = parsed.d === true;
|
||||
// Payload: { reason, resumable }. resumable=true (промах RESUME) —
|
||||
// сессия жива, сервер сразу пришлёт полный READY; разлогинивать нельзя.
|
||||
const data = asRecord(parsed.d);
|
||||
const resumable = data?.['resumable'] === true;
|
||||
if (resumable) {
|
||||
this.logger.warn('gateway: invalid session, retrying resume');
|
||||
this.dropSocket();
|
||||
this.logger.warn('gateway: invalid session, waiting for full READY');
|
||||
this.sessionId = null;
|
||||
this.lastSeq = 0;
|
||||
this.setStatus('connecting');
|
||||
return;
|
||||
}
|
||||
this.sessionId = null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export interface User {
|
||||
badges: string[];
|
||||
locale: string;
|
||||
onboarding_completed: boolean;
|
||||
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
||||
totp_enabled: boolean;
|
||||
}
|
||||
|
||||
/** Урезанное представление пользователя (участники сервера, READY). */
|
||||
@@ -52,7 +54,7 @@ export interface Role {
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
/** Битовая маска прав (строка — чтобы не терять старшие биты в JSON). */
|
||||
/** Имена прав через `|`, как их отдаёт сервер (например `VIEW_CHANNEL|SEND_MESSAGES`). */
|
||||
permissions: string;
|
||||
is_default: boolean;
|
||||
hoist: boolean;
|
||||
|
||||
Reference in New Issue
Block a user