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