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