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
+7 -4
View File
@@ -49,6 +49,9 @@ export default function AppLayout() {
selectChannel(guildId, channelId);
}, [guildId, channelId, selectedGuildId, selectChannel]);
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
const realGuildId = guildId === 'empty' ? null : guildId;
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
@@ -65,11 +68,11 @@ export default function AppLayout() {
}
}, [guilds.length, myGuilds.data, upsertGuild]);
const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId);
const guildInSnapshot = realGuildId !== null && guilds.some((guild) => guild.id === realGuildId);
const fallbackGuild = useQuery({
queryKey: guildQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
enabled: guildId !== null && !guildInSnapshot,
queryKey: guildQueryKey(realGuildId ?? ''),
queryFn: ({ signal }) => fetchGuild(realGuildId ?? '', signal),
enabled: realGuildId !== null && !guildInSnapshot,
retry: 0,
});
const hydrated = useRef<string | null>(null);