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
+4 -81
View File
@@ -1,38 +1,11 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router';
import { AuthGuard } from '@/components/AuthGuard';
import { LoadingNotice } from '@/components/ui/ErrorNotice';
import { useCurrentUser } from '@/lib/hooks';
import { router } from '@/router';
import '@/i18n';
const LoginPage = lazy(() => import('@/pages/LoginPage'));
const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
const StatusPage = lazy(() => import('@/pages/StatusPage'));
const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
const GuildView = lazy(() => import('@/pages/app/GuildView'));
const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
/** `/` — редирект по состоянию сессии. */
function IndexRedirect() {
const currentUser = useCurrentUser();
if (currentUser.isPending) {
return (
<div className="flex min-h-full items-center justify-center p-6">
<LoadingNotice />
</div>
);
}
return <Navigate to={currentUser.isSuccess ? '/app' : '/login'} replace />;
}
/** Общая заглушка на время загрузки ленивых страниц. */
/** Заглушка на время загрузки ленивых страниц. */
export function RouteFallback() {
return (
<div className="flex min-h-full items-center justify-center p-6">
@@ -41,56 +14,6 @@ export function RouteFallback() {
);
}
export const routes = [
{
path: '/',
children: [
{ index: true, element: <IndexRedirect /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: 'status', element: <StatusPage /> },
{
element: <AuthGuard allowIncompleteOnboarding />,
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
},
{
element: <AuthGuard />,
children: [
{
path: 'app',
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="empty" replace /> },
{ path: 'empty', element: <NoGuildView /> },
{ path: ':guildId', element: <GuildView /> },
{ path: ':guildId/:channelId', element: <GuildView /> },
],
},
],
},
{
element: <AuthGuard allowIncompleteOnboarding />,
children: [
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ index: true, element: <Navigate to="profile" replace /> },
{ path: 'profile', element: <ProfileSettingsPage /> },
{ path: 'security', element: <SecuritySettingsPage /> },
{ path: 'appearance', element: <AppearanceSettingsPage /> },
{ path: 'instance', element: <InstanceSettingsPage /> },
],
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
],
},
];
export const router = createBrowserRouter(routes);
export function App() {
return (
<Suspense fallback={<RouteFallback />}>