feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса

REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
@@ -0,0 +1,49 @@
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';
/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
export default function AppearanceSettingsPage() {
const { t, i18n } = useTranslation();
const theme = useUiStore((state) => state.theme);
const setTheme = useUiStore((state) => state.setTheme);
const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru';
const changeLanguage = (language: SupportedLanguage): void => {
window.localStorage.setItem('glchat.language', language);
void i18n.changeLanguage(language);
};
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.appearance.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.appearance.description')}</p>
<div className="mt-4 flex flex-col gap-4">
<SelectField
label={t('settings.appearance.theme')}
hint={t('settings.appearance.themeHint')}
value={theme}
onChange={(value) => setTheme(value === 'light' ? 'light' : ('dark' as Theme))}
options={[
{ value: 'dark', label: t('theme.dark') },
{ value: 'light', label: t('theme.light') },
]}
/>
<SelectField
label={t('settings.appearance.language')}
hint={t('settings.appearance.languageHint')}
value={locale}
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
</div>
</Card>
);
}
@@ -0,0 +1,213 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
fetchAudit,
fetchInstanceGuilds,
fetchInstanceSettings,
fetchInstanceUsers,
instanceAuditQueryKey,
instanceGuildsQueryKey,
instanceSettingsQueryKey,
instanceUsersQueryKey,
} from '@/api/instance';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { formatDateTime } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
const AUDIT_LIMIT = 50;
/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */
export default function InstanceSettingsPage() {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const currentUser = useCurrentUser();
const isAdmin = currentUser.data?.is_instance_admin === true;
const settings = useQuery({
queryKey: instanceSettingsQueryKey,
queryFn: ({ signal }) => fetchInstanceSettings(signal),
enabled: isAdmin,
retry: 0,
});
const guilds = useQuery({
queryKey: instanceGuildsQueryKey,
queryFn: ({ signal }) => fetchInstanceGuilds(signal),
enabled: isAdmin,
retry: 0,
});
const users = useQuery({
queryKey: instanceUsersQueryKey,
queryFn: ({ signal }) => fetchInstanceUsers(signal),
enabled: isAdmin,
retry: 0,
});
const audit = useQuery({
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
queryFn: ({ signal }) => fetchAudit(AUDIT_LIMIT, signal),
enabled: isAdmin,
retry: 0,
});
if (!isAdmin) {
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.notAdmin')}</p>
</Card>
);
}
const settingsEntries =
settings.data === undefined ? [] : Object.entries(settings.data).slice(0, 30);
return (
<div className="flex flex-col gap-5">
<header>
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
</header>
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.settings')}</h3>
{settings.isPending ? <LoadingNotice /> : null}
{settings.isError ? (
<ErrorNotice
className="mt-3"
error={settings.error}
onRetry={() => void settings.refetch()}
/>
) : null}
{!settings.isPending && !settings.isError && settingsEntries.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.settingsEmpty')}</p>
) : null}
{settingsEntries.length === 0 ? null : (
<dl className="mt-3 text-sm">
{settingsEntries.map(([key, value]) => (
<div
key={key}
className="flex justify-between gap-4 border-b border-border/40 py-2 last:border-b-0"
>
<dt className="text-fg-muted">{key}</dt>
<dd className="max-w-[60%] truncate text-right font-medium" title={format(value)}>
{format(value)}
</dd>
</div>
))}
</dl>
)}
</Card>
<Card>
<h3 className="text-base font-semibold">
{t('settings.instance.guilds', { count: guilds.data?.length ?? 0 })}
</h3>
{guilds.isPending ? <LoadingNotice /> : null}
{guilds.isError ? (
<ErrorNotice
className="mt-3"
error={guilds.error}
onRetry={() => void guilds.refetch()}
/>
) : null}
{guilds.data !== undefined && guilds.data.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>
) : null}
{guilds.data === undefined || guilds.data.length === 0 ? null : (
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-guilds">
{guilds.data.map((guild) => (
<li
key={guild.id}
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
>
<span className="truncate">{guild.name}</span>
<span className="shrink-0 text-xs text-fg-muted">
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
{t('settings.instance.memberCount', { count: guild.member_count })}
</span>
</li>
))}
</ul>
)}
</Card>
<Card>
<h3 className="text-base font-semibold">
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
</h3>
{users.isPending ? <LoadingNotice /> : null}
{users.isError ? (
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
) : null}
{users.data !== undefined && users.data.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.usersEmpty')}</p>
) : null}
{users.data === undefined || users.data.length === 0 ? null : (
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-users">
{users.data.map((user) => (
<li
key={user.id}
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
>
<span className="truncate">
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
</span>
{user.is_instance_admin ? (
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
{t('settings.instance.adminBadge')}
</span>
) : null}
</li>
))}
</ul>
)}
</Card>
<Card>
<h3 className="text-base font-semibold">
{t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
</h3>
{audit.isPending ? <LoadingNotice /> : null}
{audit.isError ? (
<ErrorNotice className="mt-3" error={audit.error} onRetry={() => void audit.refetch()} />
) : null}
{audit.data !== undefined && audit.data.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.auditEmpty')}</p>
) : null}
{audit.data === undefined || audit.data.length === 0 ? null : (
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-audit">
{audit.data.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
>
<span className="truncate">
{entry.action}
{entry.target === undefined ? '' : ` → ${entry.target}`}
</span>
<span className="shrink-0 text-xs text-fg-muted">
{formatDateTime(entry.created_at, locale)}
</span>
</li>
))}
</ul>
)}
</Card>
</div>
);
}
function format(value: unknown): string {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return '—';
}
}
+176
View File
@@ -0,0 +1,176 @@
import { useCallback, useState, type FormEvent } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { changePassword, stepUp } from '@/api/auth';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { errorCode } from '@/lib/format';
/**
* Смена пароля. Если сервер отвечает `auth.step_up_required`, показываем
* форму подтверждения личности (пароль + код 2FA при необходимости) и после
* успешного step-up повторяем смену пароля один раз.
*/
export function PasswordSection({ twoFactorEnabled }: { twoFactorEnabled: boolean }) {
const { t } = useTranslation();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [stepUpOpen, setStepUpOpen] = useState(false);
const [stepUpPassword, setStepUpPassword] = useState('');
const [stepUpCode, setStepUpCode] = useState('');
const [changed, setChanged] = useState(false);
const change = useMutation({
mutationFn: () => changePassword(currentPassword, newPassword),
onSuccess: () => {
setChanged(true);
setStepUpOpen(false);
setStepUpPassword('');
setStepUpCode('');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
},
onError: (error: unknown) => {
if (errorCode(error) === 'auth.step_up_required') {
setStepUpOpen(true);
}
},
});
const confirmIdentity = useMutation({
mutationFn: () => stepUp(stepUpPassword, twoFactorEnabled ? stepUpCode : undefined),
onSuccess: () => {
// Пароль подтверждён — повторяем исходное действие.
change.mutate();
},
});
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setChanged(false);
if (newPassword.length < 8) {
setFormError(t('auth.validation.passwordShort'));
return;
}
if (newPassword !== confirmPassword) {
setFormError(t('auth.validation.passwordMismatch'));
return;
}
setFormError(null);
change.mutate();
};
const closeStepUp = useCallback(() => {
setStepUpOpen(false);
}, []);
const stepUpRequired = errorCode(change.error) === 'auth.step_up_required';
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.security.passwordTitle')}</h2>
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.security.currentPassword')}
type="password"
name="current_password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
required
/>
<Field
label={t('settings.security.newPassword')}
type="password"
name="new_password"
autoComplete="new-password"
hint={t('auth.register.passwordHint')}
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
required
/>
<Field
label={t('settings.security.confirmPassword')}
type="password"
name="confirm_password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
required
/>
{formError === null ? null : (
<p className="text-xs text-danger" role="alert">
{formError}
</p>
)}
{change.isError && !stepUpRequired ? <ErrorNotice error={change.error} /> : null}
{changed ? (
<p className="text-sm text-success" role="status">
{t('settings.security.passwordChanged')}
</p>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={change.isPending}>
{t(change.isPending ? 'common.saving' : 'settings.security.submitPassword')}
</Button>
</div>
</form>
{stepUpOpen ? (
<div className="mt-5 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-4">
<h3 className="text-base font-semibold">{t('settings.security.stepUpTitle')}</h3>
<p className="mt-1 text-sm text-fg-muted">
{t('settings.security.stepUpBody', {
totp: twoFactorEnabled ? t('settings.security.stepUpTotp') : '',
})}
</p>
<form
className="mt-3 flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
confirmIdentity.mutate();
}}
>
<Field
label={t('settings.security.stepUpPassword')}
type="password"
name="step_up_password"
autoComplete="current-password"
value={stepUpPassword}
onChange={(event) => setStepUpPassword(event.target.value)}
required
/>
{twoFactorEnabled ? (
<Field
label={t('auth.login.totp')}
hint={t('auth.login.totpHint')}
name="step_up_totp"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={8}
value={stepUpCode}
onChange={(event) => setStepUpCode(event.target.value)}
/>
) : null}
{confirmIdentity.isError ? <ErrorNotice error={confirmIdentity.error} /> : null}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeStepUp}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={confirmIdentity.isPending || change.isPending}>
{t(confirmIdentity.isPending ? 'common.saving' : 'settings.security.stepUpSubmit')}
</Button>
</div>
</form>
</div>
) : null}
</Card>
);
}
@@ -0,0 +1,175 @@
import { useEffect, 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 { 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() {
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 [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) => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
setSaved(true);
if (updated.locale === 'en' || updated.locale === 'ru') {
window.localStorage.setItem('glchat.language', updated.locale);
void i18n.changeLanguage(updated.locale);
}
},
});
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setSaved(false);
save.mutate({
display_name: displayName.trim(),
bio: bio.trim(),
custom_status: customStatus.trim(),
custom_status_emoji: customEmoji.trim(),
status,
locale,
});
};
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>
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.profile.displayName')}
name="display_name"
value={displayName}
maxLength={64}
onChange={(event) => setDisplayName(event.target.value)}
/>
<TextAreaField
label={t('settings.profile.bio')}
placeholder={t('settings.profile.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
<Field
label={t('settings.profile.customStatus')}
placeholder={t('settings.profile.customStatusPlaceholder')}
name="custom_status"
maxLength={128}
value={customStatus}
onChange={(event) => setCustomStatus(event.target.value)}
/>
<Field
label={t('settings.profile.customStatusEmoji')}
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
name="custom_status_emoji"
maxLength={8}
value={customEmoji}
onChange={(event) => setCustomEmoji(event.target.value)}
/>
</div>
<SelectField
label={t('settings.profile.status')}
value={status}
onChange={setStatus}
options={userStatuses.map((value) => ({
value,
label: t(`user.status.${value}`),
}))}
/>
<SelectField
label={t('settings.profile.locale')}
value={locale}
onChange={(value) => setLocale(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
<dl className="text-sm text-fg-muted">
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.username')}</dt>
<dd className="font-medium text-fg">{user.username}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.userId')}</dt>
<dd className="font-mono text-xs text-fg">{user.id}</dd>
</div>
<div className="flex justify-between gap-4 py-2">
<dt>{t('settings.profile.badges')}</dt>
<dd className="text-fg">
{user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')}
</dd>
</div>
</dl>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{saved && !save.isError ? (
<p className="text-sm text-success" role="status">
{t('settings.profile.saved')}
</p>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={save.isPending}>
{t(save.isPending ? 'common.saving' : 'common.save')}
</Button>
</div>
</form>
</Card>
);
}
@@ -0,0 +1,31 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PasswordSection } from '@/pages/settings/PasswordSection';
import { SessionsSection } from '@/pages/settings/SessionsSection';
import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
/**
* Безопасность: смена пароля со step-up, список сессий с выходом на всех
* устройствах и включение 2FA с показом секрета и кодов восстановления.
*
* Контракт Фазы 1 не отдаёт флаг включённой 2FA в `GET /users/@me`, поэтому
* состояние «2FA включена» ведём локально: после успешного `2fa/enable`
* формы запрашивают код подтверждения.
*/
export default function SecuritySettingsPage() {
const { t } = useTranslation();
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
return (
<div className="flex flex-col gap-5">
<header>
<h2 className="text-lg font-semibold">{t('settings.security.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.security.description')}</p>
</header>
<PasswordSection twoFactorEnabled={twoFactorEnabled} />
<SessionsSection />
<TwoFactorSection enabled={twoFactorEnabled} onEnabled={() => setTwoFactorEnabled(true)} />
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { formatDateTime } from '@/lib/identity';
import { useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session';
import { getQueryClient } from '@/lib/queryClient';
/** Список активных сессий и выход на всех устройствах. */
export function SessionsSection() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const locale = i18n.resolvedLanguage ?? 'ru';
const disconnect = useGatewayStore((state) => state.disconnect);
const sessions = useQuery({
queryKey: sessionsQueryKey,
queryFn: ({ signal }) => fetchSessions(signal),
});
const logoutAllMutation = useMutation({
mutationFn: () => logoutAll(),
onSuccess: () => {
// Все сессии погашены, включая текущую: чистим состояние и уходим на вход.
disconnect();
useSessionStore.getState().reset();
getQueryClient().clear();
void navigate('/login', { replace: true });
},
});
return (
<Card>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{t('settings.security.sessionsTitle')}</h2>
<Button
variant="ghost"
disabled={logoutAllMutation.isPending}
onClick={() => {
if (window.confirm(t('settings.security.logoutAllConfirm'))) {
logoutAllMutation.mutate();
}
}}
>
{t('settings.security.logoutAll')}
</Button>
</div>
{logoutAllMutation.isError ? (
<ErrorNotice className="mt-3" error={logoutAllMutation.error} />
) : null}
{sessions.isPending ? <LoadingNotice /> : null}
{sessions.isError ? (
<ErrorNotice
className="mt-3"
error={sessions.error}
onRetry={() => void sessions.refetch()}
/>
) : null}
{sessions.data !== undefined && sessions.data.length === 0 ? (
<p className="mt-3 text-sm text-fg-muted">{t('settings.security.sessionsEmpty')}</p>
) : null}
{sessions.data !== undefined && sessions.data.length > 0 ? (
<ul className="mt-3 flex flex-col gap-2" data-testid="sessions-list">
{sessions.data.map((session) => (
<li
key={session.id}
className="rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3 text-sm"
>
<div className="flex items-center justify-between gap-3">
<span className="truncate font-medium" title={session.user_agent}>
{session.user_agent === ''
? t('settings.security.unknownAgent')
: session.user_agent}
</span>
{session.current ? (
<span className="shrink-0 rounded-full border border-success/50 px-2 text-xs text-success">
{t('settings.security.sessionCurrent')}
</span>
) : null}
</div>
<dl className="mt-2 grid gap-1 text-xs text-fg-muted sm:grid-cols-2">
<div>
<dt className="inline">{t('settings.security.sessionIp')}: </dt>
<dd className="inline">{session.ip}</dd>
</div>
<div>
<dt className="inline">{t('settings.security.sessionCreated')}: </dt>
<dd className="inline">{formatDateTime(session.created_at, locale)}</dd>
</div>
<div>
<dt className="inline">{t('settings.security.sessionSeen')}: </dt>
<dd className="inline">{formatDateTime(session.last_seen, locale)}</dd>
</div>
</dl>
</li>
))}
</ul>
) : null}
</Card>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet } from 'react-router';
import { RouteFallback } from '@/App';
import { useCurrentUser } from '@/lib/hooks';
/** Каркас настроек: вкладки профиля, безопасности, внешнего вида и инстанса. */
export default function SettingsLayout() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const tabs = [
{ to: 'profile', label: t('settings.tabs.profile') },
{ to: 'security', label: t('settings.tabs.security') },
{ to: 'appearance', label: t('settings.tabs.appearance') },
...(currentUser.data?.is_instance_admin === true
? [{ to: 'instance', label: t('settings.tabs.instance') }]
: []),
];
return (
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-5 px-4 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
<NavLink className="text-sm text-accent underline" to="/app">
{t('settings.back')}
</NavLink>
</header>
<nav aria-label={t('settings.tabs.label')} className="flex flex-wrap gap-2">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
`rounded-[var(--radius-md)] border px-3 py-1.5 text-sm ${
isActive
? 'border-accent bg-surface-3 text-fg'
: 'border-border/60 bg-surface-1 text-fg-muted hover:text-fg'
}`
}
>
{tab.label}
</NavLink>
))}
</nav>
<Suspense fallback={<RouteFallback />}>
<Outlet />
</Suspense>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { enableTotp, setupTotp } from '@/api/auth';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
/**
* Включение двухфакторной аутентификации: setup → секрет (и QR) → код →
* одноразовые коды восстановления, которые показываются ровно один раз.
*/
export function TwoFactorSection({
enabled,
onEnabled,
}: {
enabled: boolean;
onEnabled: () => void;
}) {
const { t } = useTranslation();
const [secret, setSecret] = useState<string | null>(null);
const [otpauthUrl, setOtpauthUrl] = useState('');
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [qrFailed, setQrFailed] = useState(false);
const [copied, setCopied] = useState(false);
const setup = useMutation({
mutationFn: () => setupTotp(),
onSuccess: (payload) => {
setSecret(payload.secret);
setOtpauthUrl(payload.otpauth_url);
setRecoveryCodes(null);
setQrFailed(false);
},
});
const enable = useMutation({
mutationFn: () => enableTotp(code.trim()),
onSuccess: (codes) => {
setRecoveryCodes(codes);
setSecret(null);
setCode('');
onEnabled();
},
});
const copyRecovery = (): void => {
if (recoveryCodes === null) {
return;
}
void navigator.clipboard?.writeText(recoveryCodes.join('\n'));
setCopied(true);
};
return (
<Card>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">{t('settings.security.twoFactorTitle')}</h2>
<p className="mt-1 text-sm text-fg-muted">
{t(
enabled
? 'settings.security.twoFactorEnabled'
: 'settings.security.twoFactorDisabled',
)}
</p>
</div>
{enabled || secret !== null ? null : (
<Button disabled={setup.isPending} onClick={() => setup.mutate()}>
{t('settings.security.twoFactorEnable')}
</Button>
)}
</div>
{setup.isError ? <ErrorNotice className="mt-3" error={setup.error} /> : null}
{secret !== null ? (
<div className="mt-4 flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3">
<p className="text-sm text-fg-muted">{t('settings.security.twoFactorSetupHint')}</p>
<div className="flex flex-wrap items-start gap-4">
{qrFailed || otpauthUrl === '' ? (
<p className="text-xs text-fg-muted">
{t('settings.security.twoFactorQrUnavailable')}
</p>
) : (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(
otpauthUrl,
)}`}
alt={t('settings.security.twoFactorQrAlt')}
width={160}
height={160}
className="rounded-[var(--radius-sm)] bg-white p-1"
onError={() => setQrFailed(true)}
/>
)}
<dl className="text-sm">
<dt className="text-fg-muted">{t('settings.security.twoFactorSecret')}</dt>
<dd className="mt-1 font-mono text-xs break-all" data-testid="totp-secret">
{secret}
</dd>
<dt className="mt-3 text-fg-muted">{t('settings.security.twoFactorUri')}</dt>
<dd className="mt-1 font-mono text-xs break-all">{otpauthUrl}</dd>
</dl>
</div>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (code.trim() !== '') {
enable.mutate();
}
}}
>
<Field
label={t('settings.security.twoFactorCode')}
name="totp_code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={8}
value={code}
onChange={(event) => setCode(event.target.value)}
required
/>
{enable.isError ? <ErrorNotice error={enable.error} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={enable.isPending || code.trim() === ''}>
{t(enable.isPending ? 'common.saving' : 'settings.security.twoFactorConfirm')}
</Button>
</div>
</form>
</div>
) : null}
{recoveryCodes !== null ? (
<div className="mt-4 rounded-[var(--radius-md)] border border-success/50 bg-success/10 p-3">
<p className="text-sm text-success" role="status">
{t('settings.security.twoFactorEnabledDone')}
</p>
<h3 className="mt-2 text-sm font-semibold">{t('settings.security.recoveryTitle')}</h3>
<p className="text-xs text-fg-muted">{t('settings.security.recoveryHint')}</p>
<ul
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
data-testid="recovery-codes"
>
{recoveryCodes.map((recoveryCode) => (
<li key={recoveryCode}>{recoveryCode}</li>
))}
</ul>
<div className="mt-3 flex items-center gap-3">
<Button variant="ghost" onClick={copyRecovery}>
{t(copied ? 'common.copied' : 'settings.security.recoveryCopyAll')}
</Button>
</div>
</div>
) : null}
</Card>
);
}