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
+17
View File
@@ -0,0 +1,17 @@
import { Navigate } from 'react-router';
import { LoadingNotice } from '@/components/ui/ErrorNotice';
import { useCurrentUser } from '@/lib/hooks';
/** `/` — редирект по состоянию сессии: в приложение или на вход. */
export default 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 />;
}
+84 -85
View File
@@ -1,50 +1,35 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { completeOnboarding, currentUserQueryKey } from '@/api/users';
import type { OnboardingInput } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore, type Theme } from '@/stores/ui';
import { useUiStore } from '@/stores/ui';
/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */
export default function OnboardingPage() {
interface OnboardingFormProps {
/** Имя, предложенное сервером (или из снапшота шлюза). */
suggestedName: string;
preferredLocale: SupportedLanguage;
}
/** Форма онбординга: имя, «о себе», язык и тема + POST onboarding/complete. */
function OnboardingForm({ suggestedName, preferredLocale }: OnboardingFormProps) {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const theme = useUiStore((state) => state.theme);
const setTheme = useUiStore((state) => state.setTheme);
const sessionUser = useSessionStore((state) => state.user);
const [displayName, setDisplayName] = useState('');
const [displayName, setDisplayName] = useState(suggestedName);
const [bio, setBio] = useState('');
const [locale, setLocale] = useState<SupportedLanguage>(
i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
);
const [touched, setTouched] = useState(false);
const user = currentUser.data;
const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
useEffect(() => {
if (suggestedName !== '' && !touched) {
setDisplayName((current) => (current === '' ? suggestedName : current));
}
}, [suggestedName, touched]);
useEffect(() => {
const preferred = user?.locale;
if (preferred === 'en' || preferred === 'ru') {
setLocale(preferred);
}
}, [user?.locale]);
const [locale, setLocale] = useState<SupportedLanguage>(preferredLocale);
const complete = useMutation({
mutationFn: (input: OnboardingInput) => completeOnboarding(input),
@@ -61,13 +46,8 @@ export default function OnboardingPage() {
void i18n.changeLanguage(language);
};
const changeTheme = (next: Theme): void => {
setTheme(next);
};
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setTouched(true);
const input: OnboardingInput = { locale };
const trimmedName = displayName.trim();
const trimmedBio = bio.trim();
@@ -80,7 +60,75 @@ export default function OnboardingPage() {
complete.mutate(input);
};
const unknownUser = currentUser.isError && !complete.isSuccess;
return (
<Card>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('onboarding.displayName')}
hint={t('onboarding.displayNameHint')}
name="display_name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
maxLength={64}
/>
<TextAreaField
label={t('onboarding.bio')}
placeholder={t('onboarding.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<SelectField
label={t('onboarding.language')}
value={locale}
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
<SelectField
label={t('onboarding.theme')}
value={theme}
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
options={[
{ value: 'dark', label: t('theme.dark') },
{ value: 'light', label: t('theme.light') },
]}
/>
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">{t('onboarding.note')}</p>
<Button type="submit" disabled={complete.isPending}>
{t(complete.isPending ? 'onboarding.starting' : 'onboarding.start')}
</Button>
</div>
</form>
</Card>
);
}
/** Первичная настройка: приветствие, имя, «о себе», язык и тема. */
export default function OnboardingPage() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const sessionUser = useSessionStore((state) => state.user);
const user = currentUser.data;
const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
const preferredLocale: SupportedLanguage = user?.locale === 'en' ? 'en' : 'ru';
if (currentUser.isPending) {
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
<LoadingNotice />
</main>
);
}
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
@@ -93,58 +141,9 @@ export default function OnboardingPage() {
<p className="mt-1 text-fg-muted">{t('onboarding.subtitle')}</p>
</header>
<Card>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('onboarding.displayName')}
hint={t('onboarding.displayNameHint')}
name="display_name"
value={displayName}
onChange={(event) => {
setTouched(true);
setDisplayName(event.target.value);
}}
maxLength={64}
/>
<TextAreaField
label={t('onboarding.bio')}
placeholder={t('onboarding.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<SelectField
label={t('onboarding.language')}
value={locale}
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
<SelectField
label={t('onboarding.theme')}
value={theme}
onChange={(value) => changeTheme(value === 'light' ? 'light' : 'dark')}
options={[
{ value: 'dark', label: t('theme.dark') },
{ value: 'light', label: t('theme.light') },
]}
/>
<OnboardingForm suggestedName={suggestedName} preferredLocale={preferredLocale} />
{unknownUser ? <ErrorNotice error={currentUser.error} /> : null}
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">{t('onboarding.note')}</p>
<Button type="submit" disabled={complete.isPending}>
{t(complete.isPending ? 'onboarding.starting' : 'onboarding.start')}
</Button>
</div>
</form>
</Card>
{currentUser.isError ? <ErrorNotice error={currentUser.error} /> : null}
</main>
);
}
+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);
+6 -33
View File
@@ -9,46 +9,16 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { groupChannels } from '@/lib/channels';
import { canManageGuild } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import type { Channel } from '@/api/types';
interface ChannelSidebarProps {
guildId: string | null;
channelId: string | null;
}
interface ChannelGroup {
category: Channel | null;
channels: Channel[];
}
/** Группирует комнаты по категориям, сохраняя порядок `position`. */
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;
}
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
const { t } = useTranslation();
@@ -68,7 +38,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
const [parentId, setParentId] = useState('');
const channels = guild?.channels ?? [];
const channels = useMemo(() => guild?.channels ?? [], [guild]);
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
const remoteChannels = useQuery({
@@ -115,7 +85,10 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
});
const groups = useMemo(() => groupChannels(channels), [channels]);
const categories = channels.filter((channel) => channel.type === 'category');
const categories = useMemo(
() => channels.filter((channel) => channel.type === 'category'),
[channels],
);
const canManage =
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
+19
View File
@@ -0,0 +1,19 @@
import { lazy } from 'react';
/**
* Ленивые страницы приложения. Лежат отдельно от таблицы маршрутов, чтобы
* модуль с `routes`/`router` не экспортировал компоненты (react-refresh).
*/
export const IndexRedirect = lazy(() => import('@/pages/IndexRedirect'));
export const LoginPage = lazy(() => import('@/pages/LoginPage'));
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SelectField } from '@/components/ui/Field';
import { Card } from '@/components/ui/primitives';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useUiStore, type Theme } from '@/stores/ui';
import { useUiStore } from '@/stores/ui';
/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
export default function AppearanceSettingsPage() {
@@ -27,7 +27,7 @@ export default function AppearanceSettingsPage() {
label={t('settings.appearance.theme')}
hint={t('settings.appearance.themeHint')}
value={theme}
onChange={(value) => setTheme(value === 'light' ? 'light' : ('dark' as Theme))}
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
options={[
{ value: 'dark', label: t('theme.dark') },
{ value: 'light', label: t('theme.light') },
+45 -41
View File
@@ -1,44 +1,33 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
import { userStatuses, type User } from '@/api/types';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { userStatuses } from '@/api/types';
/** Профиль: отображаемое имя, «о себе», своё состояние, статус и язык. */
export default function ProfileSettingsPage() {
/**
* Форма профиля. Состояние инициализируется из `user` при монтировании,
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
* синхронизация через эффект не нужна.
*/
function ProfileForm({ user }: { user: User }) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [customStatus, setCustomStatus] = useState('');
const [customEmoji, setCustomEmoji] = useState('');
const [status, setStatus] = useState<string>('online');
const [locale, setLocale] = useState<SupportedLanguage>('ru');
const [displayName, setDisplayName] = useState(user.display_name);
const [bio, setBio] = useState(user.bio);
const [customStatus, setCustomStatus] = useState(user.custom_status);
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
const [status, setStatus] = useState<string>(user.status);
const [locale, setLocale] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
const [saved, setSaved] = useState(false);
const user = currentUser.data;
useEffect(() => {
if (user === undefined) {
return;
}
setDisplayName(user.display_name);
setBio(user.bio);
setCustomStatus(user.custom_status);
setCustomEmoji(user.custom_status_emoji);
setStatus(user.status);
setLocale(user.locale === 'en' ? 'en' : 'ru');
}, [user]);
const save = useMutation({
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
onSuccess: (updated) => {
@@ -65,22 +54,6 @@ export default function ProfileSettingsPage() {
});
};
if (currentUser.isPending) {
return (
<Card>
<LoadingNotice />
</Card>
);
}
if (currentUser.isError || user === undefined) {
return (
<Card>
<ErrorNotice error={currentUser.error} onRetry={() => void currentUser.refetch()} />
</Card>
);
}
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
@@ -173,3 +146,34 @@ export default function ProfileSettingsPage() {
</Card>
);
}
/** Вкладка «Профиль»: ждёт загрузки профиля и передаёт данные форме. */
export default function ProfileSettingsPage() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
if (currentUser.isPending) {
return (
<Card>
<LoadingNotice />
</Card>
);
}
if (currentUser.data === undefined) {
return (
<Card>
<p className="text-sm text-fg-muted">{t('common.loading')}</p>
{currentUser.isError ? (
<ErrorNotice
className="mt-3"
error={currentUser.error}
onRetry={() => void currentUser.refetch()}
/>
) : null}
</Card>
);
}
return <ProfileForm key={currentUser.data.id} user={currentUser.data} />;
}