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:
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user