feat(cli): включение 2FA инстанс-админа командой totp-setup

2FA обязательна для инстанс-администраторов, но до её включения вход закрыт —
получался замкнутый круг. Добавлены команды обслуживания:

- `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом,
  печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый);
- `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства;
- код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить;
- установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию
  печатает подсказку, чтобы секреты не оседали в логах установки).

Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA →
totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники,
аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах
нет.
This commit is contained in:
2026-09-19 21:53:35 +03:00
parent c059cd9f51
commit bf0d130ee8
23 changed files with 1115 additions and 269 deletions
+35
View File
@@ -0,0 +1,35 @@
import type { Channel } from '@/api/types';
export interface ChannelGroup {
category: Channel | null;
channels: Channel[];
}
/**
* Группирует комнаты по категориям, сохраняя порядок `position`.
* Невидимые комнаты (`can_view === false`) сервер обычно не присылает,
* но если прислал — прячем их на клиенте.
*/
export function groupChannels(channels: Channel[]): ChannelGroup[] {
const visible = channels
.filter((channel) => channel.can_view !== false && channel.type !== 'category')
.slice()
.sort((left, right) => left.position - right.position);
const categories = channels
.filter((channel) => channel.type === 'category')
.slice()
.sort((left, right) => left.position - right.position);
const groups: ChannelGroup[] = [];
const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
if (uncategorized.length > 0) {
groups.push({ category: null, channels: uncategorized });
}
for (const category of categories) {
const children = visible.filter((channel) => channel.parent_id === category.id);
if (children.length > 0) {
groups.push({ category, channels: children });
}
}
return groups;
}
+25
View File
@@ -0,0 +1,25 @@
/** Коды, которые означают именно отсутствие сессии. */
const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']);
/** 401 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */
export function isUnauthorized(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
const candidate = error as { status?: unknown; code?: unknown };
if (candidate.status === 401) {
return true;
}
return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code);
}
/** Есть ли у объекта осмысленное строковое описание ошибки. */
export function describeUnknown(value: unknown): string {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown error';
}