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,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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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