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
+125
View File
@@ -0,0 +1,125 @@
import { useState, type FormEvent } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigate } from 'react-router';
import { login } from '@/api/auth';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { getQueryClient } from '@/lib/queryClient';
import { useInstance } from '@/lib/hooks';
import { errorCode } from '@/lib/format';
interface LocationState {
from?: string;
}
/** Форма входа: поле кода 2FA появляется при ответе `auth.2fa_required`. */
export default function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const instance = useInstance();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [needsTotp, setNeedsTotp] = useState(false);
const from = (location.state as LocationState | null)?.from ?? '/app';
const submit = useMutation({
mutationFn: () =>
login(needsTotp ? { email, password, totp_code: totpCode } : { email, password }),
onSuccess: async () => {
// Профиль перечитываем заново: cookie уже выставлена сервером.
await getQueryClient().invalidateQueries();
void navigate(from === '/login' ? '/app' : from, { replace: true });
},
onError: (error: unknown) => {
if (errorCode(error) === 'auth.2fa_required') {
setNeedsTotp(true);
}
},
});
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
submit.mutate();
};
const registrationEnabled = instance.data?.registration_enabled === true;
return (
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
<header>
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.login.title')}</h1>
<p className="mt-1 text-fg-muted">{t('auth.login.subtitle')}</p>
</header>
<Card>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('auth.login.email')}
type="email"
name="email"
autoComplete="username"
placeholder={t('auth.login.emailPlaceholder')}
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<Field
label={t('auth.login.password')}
type="password"
name="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
{needsTotp ? (
<Field
label={t('auth.login.totp')}
hint={t('auth.login.totpHint')}
name="totp_code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={8}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
autoFocus
/>
) : null}
{submit.isError ? <ErrorNotice error={submit.error} /> : null}
<Button type="submit" disabled={submit.isPending}>
{t(submit.isPending ? 'auth.login.submitting' : 'auth.login.submit')}
</Button>
</form>
<p className="mt-4 text-sm text-fg-muted">
{t('auth.login.noAccount')}{' '}
{registrationEnabled ? (
<Link className="text-accent underline" to="/register">
{t('auth.login.registerLink')}
</Link>
) : (
<span>{t('auth.login.registrationDisabled')}</span>
)}
</p>
<p className="mt-2 text-xs text-fg-muted">{t('auth.login.forgot')}</p>
</Card>
<footer className="flex items-center justify-between text-xs text-fg-muted">
<Link className="underline" to="/status">
{t('auth.login.instanceStatus')}
</Link>
<span>{instance.data?.version ?? ''}</span>
</footer>
</main>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, 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 { 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';
/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */
export default function OnboardingPage() {
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 [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 complete = useMutation({
mutationFn: (input: OnboardingInput) => completeOnboarding(input),
onSuccess: (updated) => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
void navigate('/app', { replace: true });
},
});
const changeLanguage = (language: SupportedLanguage): void => {
setLocale(language);
window.localStorage.setItem('glchat.language', language);
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();
if (trimmedName !== '') {
input.display_name = trimmedName;
}
if (trimmedBio !== '') {
input.bio = trimmedBio;
}
complete.mutate(input);
};
const unknownUser = currentUser.isError && !complete.isSuccess;
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
<header>
<h1 className="text-2xl font-semibold tracking-tight">
{suggestedName === ''
? t('onboarding.title')
: t('onboarding.greeting', { name: suggestedName })}
</h1>
<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') },
]}
/>
{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>
</main>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useState, type FormEvent } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import { register } from '@/api/auth';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useInstance } from '@/lib/hooks';
const usernamePattern = /^[A-Za-z0-9_-]{3,32}$/u;
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
interface FormErrors {
username?: string;
display_name?: string;
email?: string;
password?: string;
}
/** Регистрация: клиентские проверки, требования к паролю и учёт флага инстанса. */
export default function RegisterPage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const instance = useInstance();
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [locale, setLocale] = useState<SupportedLanguage>(
i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
);
const [errors, setErrors] = useState<FormErrors>({});
const submit = useMutation({
mutationFn: () =>
register({
username,
display_name: displayName,
email,
password,
locale,
}),
onSuccess: () => {
void navigate('/onboarding', { replace: true });
},
});
const validate = (): FormErrors => {
const next: FormErrors = {};
if (!usernamePattern.test(username)) {
next.username = t('auth.validation.usernameFormat');
}
if (displayName.trim().length === 0) {
next.display_name = t('auth.validation.displayNameRequired');
}
if (!emailPattern.test(email)) {
next.email = t('auth.validation.emailInvalid');
}
if (password.length < 8) {
next.password = t('auth.validation.passwordShort');
} else if (!/\p{L}/u.test(password)) {
next.password = t('auth.validation.passwordLetter');
} else if (!/\d/u.test(password)) {
next.password = t('auth.validation.passwordDigit');
}
return next;
};
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const next = validate();
setErrors(next);
if (Object.keys(next).length > 0) {
return;
}
submit.mutate();
};
if (instance.isPending) {
return (
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center px-4 py-10">
<LoadingNotice />
</main>
);
}
if (instance.isError) {
return (
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-4 px-4 py-10">
<ErrorNotice error={instance.error} onRetry={() => void instance.refetch()} />
</main>
);
}
if (!instance.data.registration_enabled) {
return (
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
<Card>
<h1 className="text-xl font-semibold">{t('auth.register.disabledTitle')}</h1>
<p className="mt-2 text-fg-muted">{t('auth.register.disabledBody')}</p>
</Card>
<Link className="text-sm text-accent underline" to="/login">
{t('auth.register.loginLink')}
</Link>
</main>
);
}
return (
<main className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center gap-5 px-4 py-10">
<header>
<h1 className="text-2xl font-semibold tracking-tight">{t('auth.register.title')}</h1>
<p className="mt-1 text-fg-muted">
{t('auth.register.subtitle', { instance: instance.data.name })}
</p>
</header>
<Card>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('auth.register.username')}
hint={t('auth.register.usernameHint')}
name="username"
autoComplete="username"
value={username}
error={errors.username ?? null}
onChange={(event) => setUsername(event.target.value)}
required
/>
<Field
label={t('auth.register.displayName')}
name="display_name"
autoComplete="nickname"
value={displayName}
error={errors.display_name ?? null}
onChange={(event) => setDisplayName(event.target.value)}
required
/>
<Field
label={t('auth.register.email')}
type="email"
name="email"
autoComplete="email"
value={email}
error={errors.email ?? null}
onChange={(event) => setEmail(event.target.value)}
required
/>
<Field
label={t('auth.register.password')}
type="password"
name="password"
autoComplete="new-password"
value={password}
error={errors.password ?? null}
onChange={(event) => setPassword(event.target.value)}
required
/>
<p className="text-xs text-fg-muted">
{t('auth.register.requirements', {
list: [
t('auth.register.requirementLength'),
t('auth.register.requirementLetter'),
t('auth.register.requirementDigit'),
].join(', '),
})}
</p>
<SelectField
label={t('language.label')}
value={locale}
onChange={(value) => setLocale(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
{submit.isError ? <ErrorNotice error={submit.error} /> : null}
<Button type="submit" disabled={submit.isPending}>
{t(submit.isPending ? 'auth.register.submitting' : 'auth.register.submit')}
</Button>
</form>
<p className="mt-4 text-sm text-fg-muted">
{t('auth.register.haveAccount')}{' '}
<Link className="text-accent underline" to="/login">
{t('auth.register.loginLink')}
</Link>
</p>
</Card>
</main>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { fetchReadiness, readyQueryKey } from '@/api/health';
import { fetchMeta, metaQueryKey } from '@/api/meta';
import { Badge, Button, Card } from '@/components/ui/primitives';
import { StatBytes, StatRow } from '@/components/ui/StatRow';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useUiStore } from '@/stores/ui';
/** Публичная страница состояния инстанса: мета, healthz/readyz, тема и язык. */
export default function StatusPage() {
const { t, i18n } = useTranslation();
const describeError = useApiErrorMessage();
const theme = useUiStore((state) => state.theme);
const toggleTheme = useUiStore((state) => state.toggleTheme);
const locale = i18n.resolvedLanguage ?? 'ru';
const meta = useQuery({
queryKey: metaQueryKey,
queryFn: ({ signal }) => fetchMeta(signal),
retry: 1,
});
const readiness = useQuery({
queryKey: readyQueryKey,
queryFn: ({ signal }) => fetchReadiness(signal),
retry: 1,
refetchInterval: 30_000,
});
const online = readiness.isSuccess || meta.isSuccess;
const schemaReady = readiness.data?.checks['database'] === 'ok';
const changeLanguage = (language: SupportedLanguage): void => {
window.localStorage.setItem('glchat.language', language);
void i18n.changeLanguage(language);
};
return (
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
<header className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
<p className="mt-1 text-fg-muted">{t('app.tagline')}</p>
</div>
<div className="flex items-center gap-2">
<Badge aria-live="polite" data-testid="connection-state">
<span
aria-hidden="true"
className={`h-2 w-2 rounded-full ${online ? 'bg-success' : 'bg-danger'}`}
/>
{t(online ? 'health.online' : 'health.offline')}
</Badge>
<Button variant="ghost" onClick={toggleTheme}>
{t(theme === 'dark' ? 'theme.switchToLight' : 'theme.switchToDark')}
</Button>
<label className="flex items-center gap-1 text-sm text-fg-muted">
<span className="sr-only">{t('language.label')}</span>
<select
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
value={locale.startsWith('en') ? 'en' : 'ru'}
onChange={(event) => changeLanguage(event.target.value === 'en' ? 'en' : 'ru')}
>
{supportedLanguages.map((language) => (
<option key={language} value={language}>
{t(`language.${language}`)}
</option>
))}
</select>
</label>
</div>
</header>
<Card>
<h2 className="text-lg font-semibold">{t('status.title')}</h2>
<p className="mt-1 text-fg-muted">{t('status.description')}</p>
{meta.isPending ? (
<p className="mt-4 text-fg-muted" role="status">
{t('status.connecting')}
</p>
) : null}
{meta.isError ? (
<div className="mt-4 flex items-center justify-between gap-4" role="alert">
<p className="text-danger">{describeError(meta.error)}</p>
<Button variant="ghost" onClick={() => void meta.refetch()}>
{t('status.retry')}
</Button>
</div>
) : null}
{meta.data ? (
<dl className="mt-4 text-sm">
<StatRow label={t('status.instance')} value={meta.data.name} />
<StatRow label={t('status.version')} value={meta.data.version} />
<StatRow label={t('status.apiVersion')} value={meta.data.api_version} />
<StatRow
label={t('status.schemaReady')}
value={schemaReady ? t('status.schemaOk') : (readiness.data?.status ?? '…')}
/>
<StatRow
label={t('status.maxUpload')}
value={<StatBytes bytes={meta.data.max_upload_size} locale={locale} />}
/>
</dl>
) : null}
</Card>
<div className="flex flex-wrap gap-3">
<Link className="text-sm text-accent underline" to="/login">
{t('auth.login.title')}
</Link>
<Link className="text-sm text-accent underline" to="/register">
{t('auth.register.title')}
</Link>
</div>
<footer className="mt-auto flex items-center justify-between text-xs text-fg-muted">
<span>
{t('app.name')} · {t('footer.license')}
</span>
<span className="tabular-nums">{meta.data?.commit ?? ''}</span>
</footer>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Outlet, useLocation, useNavigate } from 'react-router';
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { instanceQueryKey } from '@/api/instance';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
import { GuildRail } from '@/pages/app/GuildRail';
import { UserPanel } from '@/pages/app/UserPanel';
import { errorCode } from '@/lib/format';
/**
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
* пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
* не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
*/
export default function AppLayout() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds);
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
const selectChannel = useSessionStore((state) => state.selectChannel);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const [createOpen, setCreateOpen] = useState(false);
const [guildName, setGuildName] = useState('');
const segments = location.pathname.split('/').filter((segment) => segment !== '');
const guildId = segments[1] === undefined ? null : decodeURIComponent(segments[1]);
const channelId = segments[2] === undefined ? null : decodeURIComponent(segments[2]);
// URL — источник правды для выбранного сервера и комнаты.
useEffect(() => {
if (guildId === selectedGuildId) {
return;
}
selectChannel(guildId, channelId);
}, [guildId, channelId, selectedGuildId, selectChannel]);
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
retry: 0,
});
// Список из снапшота шлюза; если он пуст — берём REST-ответ.
useEffect(() => {
if (guilds.length > 0 || myGuilds.data === undefined) {
return;
}
for (const guild of myGuilds.data) {
upsertGuild(guild);
}
}, [guilds.length, myGuilds.data, upsertGuild]);
const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId);
const fallbackGuild = useQuery({
queryKey: guildQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
enabled: guildId !== null && !guildInSnapshot,
retry: 0,
});
const hydrated = useRef<string | null>(null);
// Сервер есть в URL, но отсутствует в снапшоте (например, после RESUME).
useEffect(() => {
const guild = fallbackGuild.data;
if (guild === undefined || hydrated.current === guild.id) {
return;
}
hydrated.current = guild.id;
upsertGuild({
id: guild.id,
name: guild.name,
owner_id: guild.owner_id,
is_main: guild.is_main,
member_count: guild.member_count,
my_role_ids: guild.my_role_ids,
my_permissions: guild.my_permissions,
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
});
}, [fallbackGuild.data, upsertGuild]);
const create = useMutation({
mutationFn: (name: string) => createGuild(name),
onSuccess: (guild) => {
setCreateOpen(false);
setGuildName('');
upsertGuild(guild);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guild.id}`);
},
});
const closeCreate = useCallback(() => {
setCreateOpen(false);
}, []);
const guildList = useMemo(
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
[guilds],
);
const canCreate = instance.data?.allow_guild_creation !== false;
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
return (
<div className="flex h-full min-h-full flex-col">
<a
href="#app-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:rounded-[var(--radius-sm)] focus:bg-surface-2 focus:px-3 focus:py-1"
>
{t('app.skipToContent')}
</a>
<div className="flex min-h-0 flex-1">
<GuildRail
guilds={guildList}
selectedGuildId={guildId}
canCreate={canCreate}
onCreate={() => setCreateOpen(true)}
/>
<ChannelSidebar guildId={guildId} channelId={channelId} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<Outlet />
</div>
<UserPanel />
</div>
</div>
<Modal
open={createOpen}
title={t('guilds.create.title')}
onClose={closeCreate}
footer={
<>
<Button variant="ghost" onClick={closeCreate}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate(guildName.trim())}
disabled={create.isPending || guildName.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (guildName.trim() !== '') {
create.mutate(guildName.trim());
}
}}
>
<Field
label={t('guilds.create.name')}
placeholder={t('guilds.create.namePlaceholder')}
value={guildName}
onChange={(event) => setGuildName(event.target.value)}
maxLength={64}
hint={t('guilds.create.hint')}
/>
{instance.data === undefined ? null : (
<p className="text-xs text-fg-muted">
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && errorCode(create.error) === null ? (
<p className="text-xs text-warning">{t('errors.auth.guild_limit_reached')}</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
</div>
);
}
+275
View File
@@ -0,0 +1,275 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router';
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
import { myGuildsQueryKey } from '@/api/users';
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 { 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();
const navigate = useNavigate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const guild = useSessionStore(
useCallback((state) => state.guilds.find((item) => item.id === guildId) ?? null, [guildId]),
);
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
const upsertChannel = useSessionStore((state) => state.upsertChannel);
const removeGuild = useSessionStore((state) => state.removeGuild);
const [createOpen, setCreateOpen] = useState(false);
const [channelName, setChannelName] = useState('');
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
const [parentId, setParentId] = useState('');
const channels = guild?.channels ?? [];
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
const remoteChannels = useQuery({
queryKey: guildChannelsQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
enabled: needsChannels,
retry: 0,
});
useEffect(() => {
if (guildId === null || remoteChannels.data === undefined) {
return;
}
setGuildChannels(guildId, remoteChannels.data);
}, [guildId, remoteChannels.data, setGuildChannels]);
const create = useMutation({
mutationFn: () =>
createChannel(guildId ?? '', {
name: channelName.trim(),
type: channelType,
...(parentId === '' ? {} : { parent_id: parentId }),
}),
onSuccess: (channel) => {
if (guildId !== null) {
upsertChannel(guildId, channel);
}
setCreateOpen(false);
setChannelName('');
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
void navigate(`/app/${guildId ?? ''}/${channel.id}`);
},
});
const leave = useMutation({
mutationFn: () => leaveGuild(guildId ?? ''),
onSuccess: () => {
if (guildId !== null) {
removeGuild(guildId);
}
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void navigate('/app/empty', { replace: true });
},
});
const groups = useMemo(() => groupChannels(channels), [channels]);
const categories = channels.filter((channel) => channel.type === 'category');
const canManage =
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
if (guildId === null) {
return (
<aside className="hidden w-60 shrink-0 border-r border-border/40 bg-surface-1 p-3 md:block" />
);
}
return (
<aside
aria-label={t('channels.title')}
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
>
<header className="flex items-center justify-between gap-2 border-b border-border/40 px-3 py-3">
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
{guild?.name ?? t('channels.notInGuild.title')}
</h1>
{canManage ? (
<button
type="button"
aria-label={t('channels.add')}
title={t('channels.add')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted hover:text-fg"
onClick={() => setCreateOpen(true)}
>
+
</button>
) : null}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
{guild === null ? (
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
) : groups.length === 0 ? (
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
) : (
<nav aria-label={t('channels.title')}>
{groups.map((group) => (
<section key={group.category?.id ?? 'uncategorized'} className="mb-3">
{group.category === null ? null : (
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{group.category.name}
</h2>
)}
<ul className="flex flex-col gap-0.5">
{group.channels.map((channel) => (
<li key={channel.id}>
<NavLink
to={`/app/${guildId}/${channel.id}`}
aria-current={channel.id === channelId ? 'page' : undefined}
title={channel.name}
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
channel.id === channelId
? 'bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
<span className="truncate">{channel.name}</span>
</NavLink>
</li>
))}
</ul>
</section>
))}
</nav>
)}
{remoteChannels.isError ? (
<ErrorNotice
className="mt-2"
error={remoteChannels.error}
onRetry={() => void remoteChannels.refetch()}
/>
) : null}
</div>
<footer className="border-t border-border/40 px-2 py-2">
<Button
variant="ghost"
className="w-full"
disabled={leave.isPending}
onClick={() => {
if (window.confirm(t('guilds.leaveConfirm', { name: guild?.name ?? '' }))) {
leave.mutate();
}
}}
>
{t('guilds.leave')}
</Button>
{leave.isError ? <ErrorNotice className="mt-2" error={leave.error} /> : null}
</footer>
<Modal
open={createOpen}
title={t('channels.create.title')}
onClose={() => setCreateOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate()}
disabled={create.isPending || channelName.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'channels.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (channelName.trim() !== '') {
create.mutate();
}
}}
>
<Field
label={t('channels.create.name')}
placeholder={t('channels.create.namePlaceholder')}
value={channelName}
onChange={(event) => setChannelName(event.target.value)}
maxLength={64}
/>
<SelectField
label={t('channels.create.type')}
value={channelType}
onChange={(value) => setChannelType(value === 'voice' ? 'voice' : 'text')}
options={[
{ value: 'text', label: t('channels.create.typeText') },
{ value: 'voice', label: t('channels.create.typeVoice') },
]}
/>
{categories.length === 0 ? null : (
<SelectField
label={t('channels.create.parent')}
value={parentId}
onChange={setParentId}
options={[
{ value: '', label: t('channels.create.noParent') },
...categories.map((category) => ({
value: category.id,
label: category.name,
})),
]}
/>
)}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
</aside>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router';
import { Avatar } from '@/components/ui/Avatar';
interface GuildRailProps {
guilds: { id: string; name: string }[];
selectedGuildId: string | null;
canCreate: boolean;
onCreate: () => void;
}
/** Левая рейка: иконки серверов-инициалов и кнопка создания сервера. */
export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) {
const { t } = useTranslation();
return (
<nav
aria-label={t('guilds.railLabel')}
className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3"
>
<ul className="flex flex-col items-center gap-2" data-testid="guild-rail">
{guilds.map((guild) => {
const active = guild.id === selectedGuildId;
return (
<li key={guild.id}>
<NavLink
to={`/app/${guild.id}`}
title={guild.name}
aria-label={t('guilds.switchTo', { name: guild.name })}
aria-current={active ? 'page' : undefined}
className={`block rounded-[var(--radius-md)] transition-[border-radius,background-color] duration-[var(--duration-fast)] ${
active
? 'rounded-[var(--radius-lg)] ring-2 ring-accent'
: 'hover:rounded-[var(--radius-lg)]'
}`}
>
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" />
</NavLink>
</li>
);
})}
</ul>
{canCreate ? (
<button
type="button"
onClick={onCreate}
aria-label={t('guilds.add')}
title={t('guilds.add')}
className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success hover:rounded-[var(--radius-lg)] hover:border-success"
>
+
</button>
) : null}
</nav>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { Card } from '@/components/ui/primitives';
import { useSessionStore } from '@/stores/session';
/**
* Область контента комнаты. В Фазе 1 — заглушка: сообщения, голос и видео
* появятся в следующих фазах, но маршрутизация и выбор комнаты уже работают.
*/
export default function GuildView() {
const { t } = useTranslation();
const params = useParams<{ guildId?: string; channelId?: string }>();
const guild = useSessionStore((state) =>
params.guildId === undefined
? null
: (state.guilds.find((item) => item.id === params.guildId) ?? null),
);
const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
if (params.channelId === undefined || channel === null) {
return (
<section id="app-content" className="flex h-full items-center justify-center p-6">
<Card className="max-w-md text-center">
<h2 className="text-lg font-semibold">{t('channels.noSelection.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('channels.noSelection.body')}</p>
</Card>
</section>
);
}
const isVoice = channel.type === 'voice';
return (
<section id="app-content" className="flex h-full flex-col">
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-3">
<span aria-hidden="true" className="text-fg-muted">
{isVoice ? '🔊' : '#'}
</span>
<h2 className="text-sm font-semibold">{channel.name}</h2>
{channel.slowmode_seconds === undefined || channel.slowmode_seconds === 0 ? null : (
<span className="text-xs text-fg-muted">slowmode {channel.slowmode_seconds}s</span>
)}
</header>
<div className="flex flex-1 items-center justify-center p-6">
<Card className="max-w-md text-center">
<h3 className="text-lg font-semibold">
{t(isVoice ? 'channels.voicePlaceholder.title' : 'channels.chatPlaceholder.title')}
</h3>
<p className="mt-2 text-sm text-fg-muted">
{t(isVoice ? 'channels.voicePlaceholder.body' : 'channels.chatPlaceholder.body', {
name: channel.name,
})}
</p>
</Card>
</div>
</section>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { joinGuild } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
/**
* Экран пустого списка серверов: присоединиться к главному серверу инстанса
* (id берём из `GET /instance`) или создать свой (кнопка есть в рейке).
*/
export default function NoGuildView() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds);
const mainGuildId = instance.data?.main_guild_id;
const alreadyMember =
mainGuildId !== undefined && guilds.some((guild) => guild.id === mainGuildId);
const join = useMutation({
mutationFn: (guildId: string) => joinGuild(guildId),
onSuccess: () => {
// Список серверов обновится снапшотом шлюза; REST — подстраховка.
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
},
});
return (
<section id="app-content" className="flex h-full items-center justify-center p-6">
<Card className="max-w-lg text-center">
<h2 className="text-lg font-semibold">{t('guilds.empty.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
{instance.isPending ? (
<p className="mt-4 text-sm text-fg-muted" role="status">
{t('common.loading')}
</p>
) : null}
{mainGuildId !== undefined && !alreadyMember ? (
<Button
className="mt-4"
disabled={join.isPending}
onClick={() => join.mutate(mainGuildId)}
>
{t(join.isPending ? 'guilds.empty.joining' : 'guilds.empty.joinMain')}
</Button>
) : null}
{mainGuildId === undefined && !instance.isPending ? (
<p className="mt-4 text-sm text-fg-muted">{t('guilds.empty.noMain')}</p>
) : null}
{join.isError ? <ErrorNotice className="mt-3" error={join.error} /> : null}
{instance.isError ? (
<ErrorNotice
className="mt-3"
error={instance.error}
onRetry={() => void instance.refetch()}
/>
) : null}
</Card>
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import { logout } from '@/api/auth';
import { Avatar } from '@/components/ui/Avatar';
import { useCurrentUser } from '@/lib/hooks';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session';
import { userStatuses, type UserStatus } from '@/api/types';
/** Панель пользователя внизу: аватар, имя, статус, настройки и выход. */
export function UserPanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const describeError = useApiErrorMessage();
const sessionUser = useSessionStore((state) => state.user);
const disconnect = useGatewayStore((state) => state.disconnect);
const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
const customStatus = sessionUser?.custom_status ?? '';
const emoji = sessionUser?.custom_status_emoji ?? '';
const seed = currentUser.data?.id ?? sessionUser?.id ?? username;
const avatarFileId = sessionUser?.avatar_file_id ?? currentUser.data?.avatar_file_id;
const signOut = useMutation({
mutationFn: () => logout(),
onSuccess: () => {
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
disconnect();
useSessionStore.getState().reset();
queryClient.clear();
void navigate('/login', { replace: true });
},
});
const statusLabel = userStatuses.includes(status)
? t(`user.status.${status}`)
: t('user.status.offline');
const onCopyId = useCallback(() => {
if (currentUser.data === undefined) {
return;
}
void navigator.clipboard?.writeText(currentUser.data.id);
}, [currentUser.data]);
return (
<footer
aria-label={t('user.panelLabel')}
className="flex items-center gap-2 border-t border-border/40 bg-surface-2/80 px-2 py-2"
>
<Avatar name={name === '' ? username : name} seed={seed} fileId={avatarFileId} size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium" title={name}>
{name === '' ? username : name}
</p>
<p className="truncate text-xs text-fg-muted" title={statusLabel}>
<span aria-hidden="true" className="mr-1">
{emoji}
</span>
{customStatus === '' ? statusLabel : `${statusLabel} · ${customStatus}`}
</p>
</div>
<button
type="button"
aria-label={t('user.copyId')}
title={t('user.copyId')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
onClick={onCopyId}
>
#
</button>
<Link
to="/settings"
aria-label={t('user.settings')}
title={t('user.settings')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
>
⚙
</Link>
<button
type="button"
aria-label={t('user.logout')}
title={t('user.logout')}
disabled={signOut.isPending}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg disabled:opacity-50"
onClick={() => signOut.mutate()}
>
⏻
</button>
{signOut.isError ? (
<span role="alert" className="max-w-40 truncate text-xs text-danger">
{describeError(signOut.error)}
</span>
) : null}
</footer>
);
}
@@ -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>
);
}