feat(cli): включение 2FA инстанс-админа командой totp-setup

2FA обязательна для инстанс-администраторов, но до её включения вход закрыт —
получался замкнутый круг. Добавлены команды обслуживания:

- `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом,
  печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый);
- `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства;
- код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить;
- установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию
  печатает подсказку, чтобы секреты не оседали в логах установки).

Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA →
totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники,
аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах
нет.
This commit is contained in:
2026-09-19 21:53:35 +03:00
parent c059cd9f51
commit bf0d130ee8
23 changed files with 1115 additions and 269 deletions
+88
View File
@@ -2,9 +2,13 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"time"
"github.com/pquerna/otp/totp"
"glchat/internal/auth"
"glchat/internal/bootstrap"
@@ -23,6 +27,10 @@ func runCLI(args []string) int {
return cliResetPassword(args[1:])
case "make-admin", "remove-admin":
return cliSetInstanceAdmin(args[0], args[1:])
case "totp-setup":
return cliTOTPSetup(args[1:])
case "totp-reset":
return cliTOTPReset(args[1:])
default:
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
return 2
@@ -136,6 +144,86 @@ func cliResetPassword(args []string) int {
})
}
// cliTOTPSetup включает 2FA для пользователя (обычно инстанс-администратора):
// 2FA обязательна для админов (AGENT.md 7.19), а до её включения вход закрыт,
// поэтому первичная настройка выполняется командой обслуживания.
func cliTOTPSetup(args []string) int {
flags := flag.NewFlagSet("totp-setup", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя (обычно администратора инстанса)")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat totp-setup: укажите --email")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
setup, err := authService.SetupTOTP(ctx, user.ID)
if err != nil {
if errors.Is(err, auth.ErrTOTPAlreadyEnabled) {
return fmt.Errorf("2FA уже включена: сбросьте секрет через glchat totp-reset")
}
return err
}
// Код подтверждения считаем из секрета: команда выполняется на сервере,
// где секрет и так доступен (расшифровка идёт внутри сервиса).
code, err := totp.GenerateCode(setup.Secret, time.Now())
if err != nil {
return fmt.Errorf("сгенерировать код подтверждения: %w", err)
}
codes, err := authService.EnableTOTP(ctx, user.ID, code)
if err != nil {
return err
}
fmt.Printf("2FA включена для %s\n", user.Username)
fmt.Printf("секрет: %s\n", setup.Secret)
fmt.Printf("otpauth: %s\n", setup.URL)
fmt.Println("резервные коды (каждый одноразовый, сохраните вне сервера):")
for _, recovery := range codes {
fmt.Printf(" %s\n", recovery)
}
return nil
})
}
// cliTOTPReset удаляет секрет 2FA (например, при потере устройства и кодов).
func cliTOTPReset(args []string) int {
flags := flag.NewFlagSet("totp-reset", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat totp-reset: укажите --email")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if err := st.DeleteTOTPSecret(ctx, user.ID); err != nil {
return err
}
fmt.Printf("секрет 2FA для %s удалён; включите заново командой totp-setup\n", user.Username)
return nil
})
}
func cliSetInstanceAdmin(command string, args []string) int {
flags := flag.NewFlagSet(command, flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
+3 -2
View File
@@ -37,10 +37,11 @@ func main() {
os.Exit(healthcheck())
}
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
// make-admin, remove-admin. Остальные команды реализует shell-обёртка glchat.
// make-admin, remove-admin, totp-setup, totp-reset. Остальные команды
// реализует shell-обёртка glchat.
if len(os.Args) > 1 {
switch os.Args[1] {
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin":
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset":
os.Exit(runCLI(os.Args[1:]))
}
}
+15
View File
@@ -78,6 +78,7 @@ PROFILE="${PROFILE:-auto}"
INSTANCE_NAME="${INSTANCE_NAME:-glchat}"
MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}"
TURN_ENABLED="${TURN_ENABLED:-true}"
@@ -118,6 +119,9 @@ install.sh — установка glchat (AGENT.md 10).
--email <email> email для Let's Encrypt (обязателен, если TLS включён)
--admin <email> логин инстанс-администратора
--password <password> пароль инстанс-администратора (иначе — интерактивный ввод)
--admin-2fa включить 2FA инстанс-админу и напечатать секрет и
резервные коды (по умолчанию включается вручную:
glchat totp-setup --email <admin>)
--instance-name <name> название инстанса (по умолчанию glchat)
Сеть:
@@ -183,6 +187,7 @@ parse_args() {
--email) ACME_EMAIL="${2:?}"; shift 2 ;;
--admin) ADMIN_EMAIL="${2:?}"; shift 2 ;;
--password) ADMIN_PASSWORD="${2:?}"; shift 2 ;;
--admin-2fa) ENABLE_ADMIN_2FA=1; shift ;;
--instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;;
--main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;;
--ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
@@ -1463,6 +1468,16 @@ bootstrap_admin() {
else
warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env"
fi
# 2FA обязательна для инстанс-админов (AGENT.md 7.19): секрет и резервные
# коды печатает команда totp-setup. По умолчанию не выполняем её, чтобы
# секреты не оседали в логах установки.
if [ "${ENABLE_ADMIN_2FA:-0}" = "1" ]; then
info "включение 2FA для инстанс-админа"
docker exec glchat-app /usr/local/bin/glchat totp-setup --email "$ADMIN_EMAIL" \
|| warn "не удалось включить 2FA автоматически: выполните glchat totp-setup --email $ADMIN_EMAIL"
else
info "2FA инстанс-админа включается вручную: glchat totp-setup --email $ADMIN_EMAIL"
fi
}
run_smoke() {
+2 -2
View File
@@ -33,8 +33,8 @@ func newAPIError(err error) apiError {
case errors.Is(err, auth.ErrTOTPInvalid):
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
case errors.Is(err, auth.ErrInstanceAdminTOTP):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required"
candidate.Message = "instance administrators must enable two-factor authentication"
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required"
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
case errors.Is(err, auth.ErrSessionExpired):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
case errors.Is(err, auth.ErrStepUpRequired):
+4 -81
View File
@@ -1,38 +1,11 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router';
import { AuthGuard } from '@/components/AuthGuard';
import { LoadingNotice } from '@/components/ui/ErrorNotice';
import { useCurrentUser } from '@/lib/hooks';
import { router } from '@/router';
import '@/i18n';
const LoginPage = lazy(() => import('@/pages/LoginPage'));
const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
const StatusPage = lazy(() => import('@/pages/StatusPage'));
const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
const GuildView = lazy(() => import('@/pages/app/GuildView'));
const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
/** `/` — редирект по состоянию сессии. */
function IndexRedirect() {
const currentUser = useCurrentUser();
if (currentUser.isPending) {
return (
<div className="flex min-h-full items-center justify-center p-6">
<LoadingNotice />
</div>
);
}
return <Navigate to={currentUser.isSuccess ? '/app' : '/login'} replace />;
}
/** Общая заглушка на время загрузки ленивых страниц. */
/** Заглушка на время загрузки ленивых страниц. */
export function RouteFallback() {
return (
<div className="flex min-h-full items-center justify-center p-6">
@@ -41,56 +14,6 @@ export function RouteFallback() {
);
}
export const routes = [
{
path: '/',
children: [
{ index: true, element: <IndexRedirect /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: 'status', element: <StatusPage /> },
{
element: <AuthGuard allowIncompleteOnboarding />,
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
},
{
element: <AuthGuard />,
children: [
{
path: 'app',
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="empty" replace /> },
{ path: 'empty', element: <NoGuildView /> },
{ path: ':guildId', element: <GuildView /> },
{ path: ':guildId/:channelId', element: <GuildView /> },
],
},
],
},
{
element: <AuthGuard allowIncompleteOnboarding />,
children: [
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ index: true, element: <Navigate to="profile" replace /> },
{ path: 'profile', element: <ProfileSettingsPage /> },
{ path: 'security', element: <SecuritySettingsPage /> },
{ path: 'appearance', element: <AppearanceSettingsPage /> },
{ path: 'instance', element: <InstanceSettingsPage /> },
],
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
],
},
];
export const router = createBrowserRouter(routes);
export function App() {
return (
<Suspense fallback={<RouteFallback />}>
+15 -4
View File
@@ -1,3 +1,5 @@
import { describeUnknown } from '@/lib/http';
import type { Channel, Role, UserStatus } from './types';
/**
@@ -96,11 +98,16 @@ export interface GatewayConnectionOptions {
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
socketFactory?: (url: string) => WebSocket;
logger?: GatewayLogger;
/** Базовая задержка переподключения; в тестах её уменьшают. */
baseBackoffMs?: number;
/** Сколько ждать HELLO после открытия сокета. */
helloTimeoutMs?: number;
}
const DEFAULT_HEARTBEAT_MS = 45_000;
const MAX_BACKOFF_MS = 30_000;
const BASE_BACKOFF_MS = 1_000;
const HELLO_TIMEOUT_MS = 15_000;
interface GatewayPacket {
op: number;
@@ -326,6 +333,8 @@ export class GatewayConnection {
private readonly handlers: GatewayHandlers;
private readonly socketFactory: (url: string) => WebSocket;
private readonly logger: GatewayLogger;
private readonly baseBackoffMs: number;
private readonly helloTimeoutMs: number;
private socket: WebSocket | null = null;
private status: GatewayStatus = 'idle';
@@ -343,6 +352,8 @@ export class GatewayConnection {
this.url = options.url;
this.handlers = options.handlers ?? {};
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
this.baseBackoffMs = options.baseBackoffMs ?? BASE_BACKOFF_MS;
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
this.socketFactory =
options.socketFactory ??
((url) => {
@@ -403,13 +414,13 @@ export class GatewayConnection {
try {
socket = this.socketFactory(this.url);
} catch (error) {
this.logger.warn(`gateway: cannot open socket (${String(error)})`);
this.logger.warn(`gateway: cannot open socket (${describeUnknown(error)})`);
this.scheduleReconnect();
return;
}
this.socket = socket;
if (reason !== undefined) {
this.logger.warn(`gateway: reconnecting (${String(reason)})`);
this.logger.warn(`gateway: reconnecting (${describeUnknown(reason)})`);
}
socket.onopen = () => {
@@ -417,7 +428,7 @@ export class GatewayConnection {
this.helloTimeout = setTimeout(() => {
this.logger.warn('gateway: HELLO timeout');
this.dropSocket();
}, 15_000);
}, this.helloTimeoutMs);
};
socket.onmessage = (event: MessageEvent) => {
@@ -464,7 +475,7 @@ export class GatewayConnection {
return;
}
this.attempt += 1;
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
const backoff = Math.min(this.baseBackoffMs * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
const jitter = Math.floor(Math.random() * 250);
this.setStatus('reconnecting');
this.reconnectTimer = setTimeout(() => {
+1 -15
View File
@@ -2,6 +2,7 @@ import { Navigate, Outlet, useLocation } from 'react-router';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { useCurrentUser } from '@/lib/hooks';
import { isUnauthorized } from '@/lib/http';
interface AuthGuardProps {
/**
@@ -45,18 +46,3 @@ export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps)
return <Outlet context={{ user }} />;
}
/** Коды, которые означают именно отсутствие сессии. */
const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']);
/** 401/403 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */
export function isUnauthorized(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
const candidate = error as { status?: unknown; code?: unknown };
if (candidate.status === 401) {
return true;
}
return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Channel } from '@/api/types';
export interface ChannelGroup {
category: Channel | null;
channels: Channel[];
}
/**
* Группирует комнаты по категориям, сохраняя порядок `position`.
* Невидимые комнаты (`can_view === false`) сервер обычно не присылает,
* но если прислал — прячем их на клиенте.
*/
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;
}
+25
View File
@@ -0,0 +1,25 @@
/** Коды, которые означают именно отсутствие сессии. */
const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']);
/** 401 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */
export function isUnauthorized(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
const candidate = error as { status?: unknown; code?: unknown };
if (candidate.status === 401) {
return true;
}
return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code);
}
/** Есть ли у объекта осмысленное строковое описание ошибки. */
export function describeUnknown(value: unknown): string {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown error';
}
+17
View File
@@ -0,0 +1,17 @@
import { Navigate } from 'react-router';
import { LoadingNotice } from '@/components/ui/ErrorNotice';
import { useCurrentUser } from '@/lib/hooks';
/** `/` — редирект по состоянию сессии: в приложение или на вход. */
export default function IndexRedirect() {
const currentUser = useCurrentUser();
if (currentUser.isPending) {
return (
<div className="flex min-h-full items-center justify-center p-6">
<LoadingNotice />
</div>
);
}
return <Navigate to={currentUser.isSuccess ? '/app' : '/login'} replace />;
}
+84 -85
View File
@@ -1,50 +1,35 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { completeOnboarding, currentUserQueryKey } from '@/api/users';
import type { OnboardingInput } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore, type Theme } from '@/stores/ui';
import { useUiStore } from '@/stores/ui';
/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */
export default function OnboardingPage() {
interface OnboardingFormProps {
/** Имя, предложенное сервером (или из снапшота шлюза). */
suggestedName: string;
preferredLocale: SupportedLanguage;
}
/** Форма онбординга: имя, «о себе», язык и тема + POST onboarding/complete. */
function OnboardingForm({ suggestedName, preferredLocale }: OnboardingFormProps) {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const theme = useUiStore((state) => state.theme);
const setTheme = useUiStore((state) => state.setTheme);
const sessionUser = useSessionStore((state) => state.user);
const [displayName, setDisplayName] = useState('');
const [displayName, setDisplayName] = useState(suggestedName);
const [bio, setBio] = useState('');
const [locale, setLocale] = useState<SupportedLanguage>(
i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
);
const [touched, setTouched] = useState(false);
const user = currentUser.data;
const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
useEffect(() => {
if (suggestedName !== '' && !touched) {
setDisplayName((current) => (current === '' ? suggestedName : current));
}
}, [suggestedName, touched]);
useEffect(() => {
const preferred = user?.locale;
if (preferred === 'en' || preferred === 'ru') {
setLocale(preferred);
}
}, [user?.locale]);
const [locale, setLocale] = useState<SupportedLanguage>(preferredLocale);
const complete = useMutation({
mutationFn: (input: OnboardingInput) => completeOnboarding(input),
@@ -61,13 +46,8 @@ export default function OnboardingPage() {
void i18n.changeLanguage(language);
};
const changeTheme = (next: Theme): void => {
setTheme(next);
};
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setTouched(true);
const input: OnboardingInput = { locale };
const trimmedName = displayName.trim();
const trimmedBio = bio.trim();
@@ -80,7 +60,75 @@ export default function OnboardingPage() {
complete.mutate(input);
};
const unknownUser = currentUser.isError && !complete.isSuccess;
return (
<Card>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('onboarding.displayName')}
hint={t('onboarding.displayNameHint')}
name="display_name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
maxLength={64}
/>
<TextAreaField
label={t('onboarding.bio')}
placeholder={t('onboarding.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<SelectField
label={t('onboarding.language')}
value={locale}
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
<SelectField
label={t('onboarding.theme')}
value={theme}
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
options={[
{ value: 'dark', label: t('theme.dark') },
{ value: 'light', label: t('theme.light') },
]}
/>
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">{t('onboarding.note')}</p>
<Button type="submit" disabled={complete.isPending}>
{t(complete.isPending ? 'onboarding.starting' : 'onboarding.start')}
</Button>
</div>
</form>
</Card>
);
}
/** Первичная настройка: приветствие, имя, «о себе», язык и тема. */
export default function OnboardingPage() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const sessionUser = useSessionStore((state) => state.user);
const user = currentUser.data;
const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
const preferredLocale: SupportedLanguage = user?.locale === 'en' ? 'en' : 'ru';
if (currentUser.isPending) {
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
<LoadingNotice />
</main>
);
}
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col justify-center gap-5 px-4 py-10">
@@ -93,58 +141,9 @@ export default function OnboardingPage() {
<p className="mt-1 text-fg-muted">{t('onboarding.subtitle')}</p>
</header>
<Card>
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('onboarding.displayName')}
hint={t('onboarding.displayNameHint')}
name="display_name"
value={displayName}
onChange={(event) => {
setTouched(true);
setDisplayName(event.target.value);
}}
maxLength={64}
/>
<TextAreaField
label={t('onboarding.bio')}
placeholder={t('onboarding.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<SelectField
label={t('onboarding.language')}
value={locale}
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
<SelectField
label={t('onboarding.theme')}
value={theme}
onChange={(value) => changeTheme(value === 'light' ? 'light' : 'dark')}
options={[
{ value: 'dark', label: t('theme.dark') },
{ value: 'light', label: t('theme.light') },
]}
/>
<OnboardingForm suggestedName={suggestedName} preferredLocale={preferredLocale} />
{unknownUser ? <ErrorNotice error={currentUser.error} /> : null}
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">{t('onboarding.note')}</p>
<Button type="submit" disabled={complete.isPending}>
{t(complete.isPending ? 'onboarding.starting' : 'onboarding.start')}
</Button>
</div>
</form>
</Card>
{currentUser.isError ? <ErrorNotice error={currentUser.error} /> : null}
</main>
);
}
+7 -4
View File
@@ -49,6 +49,9 @@ export default function AppLayout() {
selectChannel(guildId, channelId);
}, [guildId, channelId, selectedGuildId, selectChannel]);
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
const realGuildId = guildId === 'empty' ? null : guildId;
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
@@ -65,11 +68,11 @@ export default function AppLayout() {
}
}, [guilds.length, myGuilds.data, upsertGuild]);
const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId);
const guildInSnapshot = realGuildId !== null && guilds.some((guild) => guild.id === realGuildId);
const fallbackGuild = useQuery({
queryKey: guildQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
enabled: guildId !== null && !guildInSnapshot,
queryKey: guildQueryKey(realGuildId ?? ''),
queryFn: ({ signal }) => fetchGuild(realGuildId ?? '', signal),
enabled: realGuildId !== null && !guildInSnapshot,
retry: 0,
});
const hydrated = useRef<string | null>(null);
+6 -33
View File
@@ -9,46 +9,16 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { groupChannels } from '@/lib/channels';
import { canManageGuild } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import type { Channel } from '@/api/types';
interface ChannelSidebarProps {
guildId: string | null;
channelId: string | null;
}
interface ChannelGroup {
category: Channel | null;
channels: Channel[];
}
/** Группирует комнаты по категориям, сохраняя порядок `position`. */
export function groupChannels(channels: Channel[]): ChannelGroup[] {
const visible = channels
.filter((channel) => channel.can_view !== false && channel.type !== 'category')
.slice()
.sort((left, right) => left.position - right.position);
const categories = channels
.filter((channel) => channel.type === 'category')
.slice()
.sort((left, right) => left.position - right.position);
const groups: ChannelGroup[] = [];
const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
if (uncategorized.length > 0) {
groups.push({ category: null, channels: uncategorized });
}
for (const category of categories) {
const children = visible.filter((channel) => channel.parent_id === category.id);
if (children.length > 0) {
groups.push({ category, channels: children });
}
}
return groups;
}
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
const { t } = useTranslation();
@@ -68,7 +38,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
const [parentId, setParentId] = useState('');
const channels = guild?.channels ?? [];
const channels = useMemo(() => guild?.channels ?? [], [guild]);
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
const remoteChannels = useQuery({
@@ -115,7 +85,10 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
});
const groups = useMemo(() => groupChannels(channels), [channels]);
const categories = channels.filter((channel) => channel.type === 'category');
const categories = useMemo(
() => channels.filter((channel) => channel.type === 'category'),
[channels],
);
const canManage =
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
+19
View File
@@ -0,0 +1,19 @@
import { lazy } from 'react';
/**
* Ленивые страницы приложения. Лежат отдельно от таблицы маршрутов, чтобы
* модуль с `routes`/`router` не экспортировал компоненты (react-refresh).
*/
export const IndexRedirect = lazy(() => import('@/pages/IndexRedirect'));
export const LoginPage = lazy(() => import('@/pages/LoginPage'));
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SelectField } from '@/components/ui/Field';
import { Card } from '@/components/ui/primitives';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useUiStore, type Theme } from '@/stores/ui';
import { useUiStore } from '@/stores/ui';
/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
export default function AppearanceSettingsPage() {
@@ -27,7 +27,7 @@ export default function AppearanceSettingsPage() {
label={t('settings.appearance.theme')}
hint={t('settings.appearance.themeHint')}
value={theme}
onChange={(value) => setTheme(value === 'light' ? 'light' : ('dark' as Theme))}
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
options={[
{ value: 'dark', label: t('theme.dark') },
{ value: 'light', label: t('theme.light') },
+45 -41
View File
@@ -1,44 +1,33 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
import { userStatuses, type User } from '@/api/types';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { userStatuses } from '@/api/types';
/** Профиль: отображаемое имя, «о себе», своё состояние, статус и язык. */
export default function ProfileSettingsPage() {
/**
* Форма профиля. Состояние инициализируется из `user` при монтировании,
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
* синхронизация через эффект не нужна.
*/
function ProfileForm({ user }: { user: User }) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [customStatus, setCustomStatus] = useState('');
const [customEmoji, setCustomEmoji] = useState('');
const [status, setStatus] = useState<string>('online');
const [locale, setLocale] = useState<SupportedLanguage>('ru');
const [displayName, setDisplayName] = useState(user.display_name);
const [bio, setBio] = useState(user.bio);
const [customStatus, setCustomStatus] = useState(user.custom_status);
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
const [status, setStatus] = useState<string>(user.status);
const [locale, setLocale] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
const [saved, setSaved] = useState(false);
const user = currentUser.data;
useEffect(() => {
if (user === undefined) {
return;
}
setDisplayName(user.display_name);
setBio(user.bio);
setCustomStatus(user.custom_status);
setCustomEmoji(user.custom_status_emoji);
setStatus(user.status);
setLocale(user.locale === 'en' ? 'en' : 'ru');
}, [user]);
const save = useMutation({
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
onSuccess: (updated) => {
@@ -65,22 +54,6 @@ export default function ProfileSettingsPage() {
});
};
if (currentUser.isPending) {
return (
<Card>
<LoadingNotice />
</Card>
);
}
if (currentUser.isError || user === undefined) {
return (
<Card>
<ErrorNotice error={currentUser.error} onRetry={() => void currentUser.refetch()} />
</Card>
);
}
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
@@ -173,3 +146,34 @@ export default function ProfileSettingsPage() {
</Card>
);
}
/** Вкладка «Профиль»: ждёт загрузки профиля и передаёт данные форме. */
export default function ProfileSettingsPage() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
if (currentUser.isPending) {
return (
<Card>
<LoadingNotice />
</Card>
);
}
if (currentUser.data === undefined) {
return (
<Card>
<p className="text-sm text-fg-muted">{t('common.loading')}</p>
{currentUser.isError ? (
<ErrorNotice
className="mt-3"
error={currentUser.error}
onRetry={() => void currentUser.refetch()}
/>
) : null}
</Card>
);
}
return <ProfileForm key={currentUser.data.id} user={currentUser.data} />;
}
+69
View File
@@ -0,0 +1,69 @@
import { createBrowserRouter, Navigate } from 'react-router';
import { AuthGuard } from '@/components/AuthGuard';
import {
AppearanceSettingsPage,
AppLayout,
IndexRedirect,
GuildView,
InstanceSettingsPage,
LoginPage,
NoGuildView,
OnboardingPage,
ProfileSettingsPage,
RegisterPage,
SecuritySettingsPage,
SettingsLayout,
StatusPage,
} from '@/pages/lazyPages';
/** Таблица маршрутов: публичные страницы, защищённые и настройки. */
export const routes = [
{
path: '/',
children: [
{ index: true, element: <IndexRedirect /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'register', element: <RegisterPage /> },
{ path: 'status', element: <StatusPage /> },
{
element: <AuthGuard allowIncompleteOnboarding />,
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
},
{
element: <AuthGuard />,
children: [
{
path: 'app',
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="empty" replace /> },
{ path: 'empty', element: <NoGuildView /> },
{ path: ':guildId', element: <GuildView /> },
{ path: ':guildId/:channelId', element: <GuildView /> },
],
},
],
},
{
element: <AuthGuard allowIncompleteOnboarding />,
children: [
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ index: true, element: <Navigate to="profile" replace /> },
{ path: 'profile', element: <ProfileSettingsPage /> },
{ path: 'security', element: <SecuritySettingsPage /> },
{ path: 'appearance', element: <AppearanceSettingsPage /> },
{ path: 'instance', element: <InstanceSettingsPage /> },
],
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
],
},
];
export const router = createBrowserRouter(routes);
+191
View File
@@ -0,0 +1,191 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
GatewayConnection,
GatewayOp,
parseGatewaySnapshot,
type GatewayDispatch,
type GatewayStatus,
} from '@/api/gateway';
import { makeReadySnapshot } from './helpers';
/** Подставной WebSocket: рукопожатие и heartbeat полностью под контролем теста. */
class FakeSocket {
static instances: FakeSocket[] = [];
readonly url: string;
readyState = 0;
sent: string[] = [];
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(url: string) {
this.url = url;
FakeSocket.instances.push(this);
}
open(): void {
this.readyState = 1;
this.onopen?.(new Event('open'));
}
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.readyState = 3;
}
/** Присылает кадр от сервера. */
emit(packet: unknown): void {
this.onmessage?.({ data: JSON.stringify(packet) } as MessageEvent);
}
/** Последний отправленный пакет в виде объекта. */
lastSent(): { op: number; d: unknown } {
const raw = this.sent.at(-1);
if (raw === undefined) {
throw new Error('socket has not sent anything');
}
return JSON.parse(raw) as { op: number; d: unknown };
}
closeFromServer(code = 1006): void {
this.readyState = 3;
this.onclose?.({ code } as CloseEvent);
}
}
function makeConnection(handlers: {
onDispatch?: (event: GatewayDispatch) => void;
onStatus?: (status: GatewayStatus) => void;
onInvalidSession?: () => void;
onResumeIncomplete?: () => void;
}) {
FakeSocket.instances = [];
const connection = new GatewayConnection({
url: 'ws://localhost/gateway',
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
handlers,
// Быстрый backoff, чтобы тест переподключения не ждал секунду.
baseBackoffMs: 5,
helloTimeoutMs: 60_000,
});
connection.connect();
connections.push(connection);
return connection;
}
const connections: GatewayConnection[] = [];
afterEach(() => {
// Закрываем соединения, иначе таймеры heartbeat продолжают жить между тестами.
for (const connection of connections.splice(0)) {
connection.close();
}
FakeSocket.instances = [];
});
describe('gateway client', () => {
it('answers HELLO with IDENTIFY and an empty token', () => {
const connection = makeConnection({});
const socket = FakeSocket.instances[0];
expect(socket).toBeDefined();
socket?.open();
socket?.emit({
op: GatewayOp.HELLO,
d: { heartbeat_interval_ms: 1000, session_id: 'sess-1' },
});
expect(socket?.lastSent()).toEqual({ op: GatewayOp.IDENTIFY, d: { token: '', resume_seq: 0 } });
expect(connection.getStatus()).toBe('connecting');
connection.close();
});
it('delivers READY to the dispatch handler', () => {
const events: GatewayDispatch[] = [];
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 1, d: makeReadySnapshot([
{ id: 'g-1', name: 'Main', channels: [{ id: 'c-1', name: 'general' }] },
]) });
expect(events).toHaveLength(1);
expect(events[0]?.t).toBe('READY');
expect(connection.getStatus()).toBe('connected');
expect(connection.getLastSeq()).toBe(1);
const snapshot = parseGatewaySnapshot(events[0]?.d);
expect(snapshot?.guilds[0]?.channels[0]?.name).toBe('general');
connection.close();
});
it('ignores unknown events and unparsable frames', () => {
const events: GatewayDispatch[] = [];
const connection = makeConnection({ onDispatch: (event) => events.push(event) });
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.DISPATCH, t: 'GUILD_CREATE', s: 2, d: { id: 'g-9' } });
socket?.emit({ op: 99, d: null });
socket?.onmessage?.({ data: 'not json' } as MessageEvent);
expect(events.map((event) => event.t)).toEqual(['GUILD_CREATE']);
connection.close();
});
it('sends HEARTBEAT with the interval from HELLO and tracks the ack', async () => {
const connection = makeConnection({});
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 20, session_id: 's' } });
await new Promise((resolve) => setTimeout(resolve, 45));
const heartbeat = socket?.lastSent();
expect(heartbeat).toEqual({ op: GatewayOp.HEARTBEAT, d: null });
socket?.emit({ op: GatewayOp.HEARTBEAT_ACK, d: null });
connection.close();
});
it('reconnects with RESUME and the last sequence number', async () => {
const statuses: GatewayStatus[] = [];
const connection = makeConnection({ onStatus: (status) => statuses.push(status) });
const first = FakeSocket.instances[0];
first?.open();
first?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
first?.emit({ op: GatewayOp.DISPATCH, t: 'READY', s: 42, d: makeReadySnapshot([]) });
first?.closeFromServer();
expect(connection.getStatus()).toBe('reconnecting');
await new Promise((resolve) => setTimeout(resolve, 200));
const second = FakeSocket.instances[1];
expect(second).toBeDefined();
second?.open();
second?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 'sess-7' } });
expect(second?.lastSent()).toEqual({ op: GatewayOp.RESUME, d: { token: '', resume_seq: 42 } });
expect(statuses).toContain('reconnecting');
connection.close();
});
it('reports INVALID_SESSION to the handler', () => {
let invalidated = 0;
const connection = makeConnection({ onInvalidSession: () => (invalidated += 1) });
const socket = FakeSocket.instances[0];
socket?.open();
socket?.emit({ op: GatewayOp.HELLO, d: { heartbeat_interval_ms: 1000, session_id: 's' } });
socket?.emit({ op: GatewayOp.INVALID_SESSION, d: false });
expect(invalidated).toBe(1);
expect(connection.getStatus()).toBe('disconnected');
connection.close();
});
});
+176
View File
@@ -0,0 +1,176 @@
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { vi } from 'vitest';
import { routes } from '@/router';
import { setQueryClient } from '@/lib/queryClient';
import { useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session';
import type { User } from '@/api/types';
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
export function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
/** Ошибка в формате контракта: 4xx + `{"error":{"code","message"}}`. */
export function apiError(code: string, status = 400, message = 'error'): Response {
return json({ error: { code, message } }, status);
}
export interface FetchRoute {
/** Подстрока URL, по которой выбирается обработчик. */
match: string;
method?: string;
response: () => Response | Promise<Response>;
}
/**
* Мок fetch по таблице маршрутов. Метод сравнивается строго (по умолчанию GET),
* URL — по вхождению подстроки. Более длинные шаблоны проверяются раньше,
* поэтому `/users/@me/guilds` не перехватывается записью `/users/@me`.
*/
export function installFetch(routes: FetchRoute[]) {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
const ordered = [...routes].sort((left, right) => right.match.length - left.match.length);
for (const route of ordered) {
if ((route.method ?? 'GET').toUpperCase() !== method) {
continue;
}
if (!url.includes(route.match)) {
continue;
}
return Promise.resolve(route.response());
}
return Promise.resolve(apiError('not_found', 404));
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
/** Мок fetch, который падает на уровне сети (сервер недоступен). */
export function installFailingFetch() {
const fetchMock = vi.fn(() => Promise.reject(new TypeError('Failed to fetch')));
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
export function renderApp(initialPath = '/') {
const queryClient = createTestQueryClient();
// Стор шлюза обращается к общему клиенту (INVALID_SESSION → сброс кэша),
// поэтому подменяем его на тестовый.
setQueryClient(queryClient);
useSessionStore.getState().reset();
useGatewayStore.setState({
status: 'idle',
heartbeatIntervalMs: null,
sessionId: null,
attempts: 0,
invalidated: false,
});
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
const view = render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return { ...view, router, queryClient };
}
/**
* Ждёт, пока отрисуется ленивая страница: react-router подгружает её
* асинхронно, поэтому сразу после render DOM ещё пуст.
*/
export async function waitForPage(anchor = 'glchat') {
await waitFor(() => {
expect(document.body.textContent ?? '').toContain(anchor);
});
}
/** Ждёт появления поля по label и возвращает его. */
export async function findField(label: string): Promise<HTMLElement> {
return screen.findByLabelText(label);
}
/** Полный профиль пользователя для мока `GET /users/@me`. */
export function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
bio: '',
status: 'online',
custom_status: '',
custom_status_emoji: '',
is_instance_admin: false,
badges: [],
locale: 'ru',
onboarding_completed: true,
...overrides,
};
}
interface SnapshotGuildInput {
id: string;
name: string;
is_main?: boolean;
channels?: {
id: string;
name: string;
type?: 'text' | 'voice' | 'category';
position?: number;
parent_id?: string;
can_send?: boolean;
can_view?: boolean;
}[];
}
/** Снапшот READY в формате шлюза. */
export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
return {
user: {
id: 'user-1',
username: 'alice',
display_name: 'Alice',
is_instance_admin: false,
badges: [],
},
guilds: guilds.map((guild) => ({
id: guild.id,
name: guild.name,
owner_id: 'user-1',
is_main: guild.is_main ?? false,
channels: (guild.channels ?? []).map((channel, index) => ({
id: channel.id,
name: channel.name,
type: channel.type ?? 'text',
position: channel.position ?? index,
...(channel.parent_id === undefined ? {} : { parent_id: channel.parent_id }),
...(channel.can_send === undefined ? {} : { can_send: channel.can_send }),
...(channel.can_view === undefined ? {} : { can_view: channel.can_view }),
})),
roles: [],
member_ids: ['user-1'],
my_role_ids: [],
my_permissions: [],
})),
dm_channels: [],
read_states: [],
session_id: 'session-1',
heartbeat_interval_ms: 45_000,
};
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { apiError, installFetch, json, makeUser, renderApp } from './helpers';
const instanceOpen = {
name: 'glchat-test',
version: 'v0.1.0-test',
registration_enabled: true,
allow_guild_creation: true,
max_guilds_per_user: 5,
max_members_per_guild: 100,
max_message_length: 2000,
main_guild_id: 'g-main',
user_count: 1,
guild_count: 1,
};
describe('страница входа', () => {
it('успешный вход переводит в /app', async () => {
const user = makeUser();
const fetchMock = installFetch([
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) },
]);
const { router } = renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
expect(
await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
).toBeVisible();
const loginCall = fetchMock.mock.calls.find(([input]) =>
String(input).includes('/auth/login'),
);
expect(loginCall).toBeDefined();
const init = loginCall?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
});
});
it('при auth.2fa_required показывает поле кода и повторяет вход с totp_code', async () => {
const user = makeUser();
let loginAttempts = 0;
const fetchMock = installFetch([
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
{
match: '/api/v1/auth/login',
method: 'POST',
response: () => {
loginAttempts += 1;
return loginAttempts === 1 ? apiError('auth.2fa_required', 401) : json({ user });
},
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
const { router } = renderApp('/login');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
const totpField = await screen.findByLabelText('Код 2FA');
expect(totpField).toBeVisible();
await visitor.type(totpField, '123456');
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
const secondLogin = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/auth/login'),
)[1];
const init = secondLogin?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
email: 'alice@example.com',
password: 'super-secret-1',
totp_code: '123456',
});
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { installFetch, json, makeUser, renderApp } from './helpers';
const instanceBase = {
name: 'glchat-test',
version: 'v0.1.0-test',
allow_guild_creation: true,
max_guilds_per_user: 5,
max_members_per_guild: 100,
max_message_length: 2000,
user_count: 1,
guild_count: 0,
};
describe('страница регистрации', () => {
it('успешная регистрация ведёт на /onboarding', async () => {
const user = makeUser({ onboarding_completed: false });
const fetchMock = installFetch([
{
match: '/api/v1/instance',
response: () => json({ instance: { ...instanceBase, registration_enabled: true } }),
},
{ match: '/api/v1/auth/register', method: 'POST', response: () => json({ user }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
]);
const { router } = renderApp('/register');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Имя пользователя'), 'alice');
await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса');
await visitor.type(screen.getByLabelText('Почта'), 'alice@example.com');
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/onboarding');
});
expect(await screen.findByText('Привет, Alice!')).toBeVisible();
const registerCall = fetchMock.mock.calls.find(([input]) =>
String(input).includes('/auth/register'),
);
const init = registerCall?.[1] as RequestInit;
expect(JSON.parse(String(init.body))).toEqual({
username: 'alice',
display_name: 'Алиса',
email: 'alice@example.com',
password: 'super-secret-1',
locale: 'ru',
});
});
it('не отправляет форму с некорректными данными', async () => {
const fetchMock = installFetch([
{
match: '/api/v1/instance',
response: () => json({ instance: { ...instanceBase, registration_enabled: true } }),
},
]);
renderApp('/register');
const visitor = userEvent.setup();
await visitor.type(await screen.findByLabelText('Имя пользователя'), 'al');
await visitor.type(screen.getByLabelText('Отображаемое имя'), 'Алиса');
await visitor.type(screen.getByLabelText('Почта'), 'not-an-email');
await visitor.type(screen.getByLabelText('Пароль'), 'short');
await visitor.click(screen.getByRole('button', { name: 'Создать аккаунт' }));
expect(await screen.findByText(/Имя пользователя: 3–32 символа/u)).toBeVisible();
expect(screen.getByText('Укажите корректный адрес почты.')).toBeVisible();
expect(screen.getByText('Пароль должен быть не короче 8 символов.')).toBeVisible();
expect(
fetchMock.mock.calls.filter(([input]) => String(input).includes('/auth/register')),
).toHaveLength(0);
});
it('при registration_enabled=false показывает сообщение вместо формы', async () => {
installFetch([
{
match: '/api/v1/instance',
response: () => json({ instance: { ...instanceBase, registration_enabled: false } }),
},
]);
renderApp('/register');
expect(await screen.findByText('Регистрация закрыта')).toBeVisible();
expect(screen.queryByLabelText('Имя пользователя')).toBeNull();
expect(screen.queryByRole('button', { name: 'Создать аккаунт' })).toBeNull();
});
});
+110
View File
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it } from 'vitest';
import { act } from '@testing-library/react';
import { parseGatewaySnapshot } from '@/api/gateway';
import { dispatchGatewayEvent, useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session';
import { makeReadySnapshot } from './helpers';
afterEach(() => {
useSessionStore.getState().reset();
useGatewayStore.setState({
status: 'idle',
heartbeatIntervalMs: null,
sessionId: null,
attempts: 0,
invalidated: false,
});
});
describe('session store', () => {
it('applies a READY snapshot: guilds, channels and session id', () => {
const snapshot = parseGatewaySnapshot(
makeReadySnapshot([
{
id: 'g-1',
name: 'Main',
is_main: true,
channels: [
{ id: 'c-1', name: 'general', position: 0 },
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
],
},
]),
);
expect(snapshot).not.toBeNull();
act(() => {
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: makeReadySnapshot([
{
id: 'g-1',
name: 'Main',
is_main: true,
channels: [
{ id: 'c-1', name: 'general', position: 0 },
{ id: 'c-2', name: 'voice', type: 'voice', position: 1 },
],
},
]) });
});
const state = useSessionStore.getState();
expect(state.guilds).toHaveLength(1);
expect(state.guilds[0]?.name).toBe('Main');
expect(state.guilds[0]?.channels.map((channel) => channel.id)).toEqual(['c-1', 'c-2']);
expect(state.sessionId).toBe('session-1');
expect(state.user?.display_name).toBe('Alice');
expect(useGatewayStore.getState().status).toBe('connected');
expect(useGatewayStore.getState().heartbeatIntervalMs).toBe(45_000);
});
it('keeps channels with can_send=false (only can_view=false is hidden)', () => {
act(() => {
dispatchGatewayEvent({
op: 0,
t: 'READY',
s: 2,
d: makeReadySnapshot([
{
id: 'g-1',
name: 'Main',
channels: [
{ id: 'c-readonly', name: 'announcements', can_send: false },
{ id: 'c-hidden', name: 'secret', can_view: false },
],
},
]),
});
});
const channels = useSessionStore.getState().guilds[0]?.channels ?? [];
expect(channels.map((channel) => channel.id)).toEqual(['c-readonly', 'c-hidden']);
});
it('ignores unknown events and malformed snapshots', () => {
act(() => {
dispatchGatewayEvent({ op: 0, t: 'SOMETHING_NEW', s: 3, d: { anything: true } });
dispatchGatewayEvent({ op: 0, t: 'READY', s: 4, d: { guilds: [] } });
});
expect(useSessionStore.getState().guilds).toEqual([]);
expect(useSessionStore.getState().user).toBeNull();
});
it('drops a guild and clears the selection when leaving', () => {
act(() => {
dispatchGatewayEvent({
op: 0,
t: 'READY',
s: 5,
d: makeReadySnapshot([{ id: 'g-1', name: 'Main' }]),
});
useSessionStore.getState().selectChannel('g-1', null);
useSessionStore.getState().removeGuild('g-1');
});
const state = useSessionStore.getState();
expect(state.guilds).toEqual([]);
expect(state.selectedGuildId).toBeNull();
});
});
+5
View File
@@ -2,6 +2,10 @@ import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
// Общий экземпляр i18next для всех тестов: без него useTranslation вернёт ключи.
import '@/i18n';
import { setQueryClient } from '@/lib/queryClient';
afterEach(() => {
cleanup();
// Globals stubbed with vi.stubGlobal must be dropped between tests,
@@ -9,4 +13,5 @@ afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
window.localStorage.clear();
setQueryClient(null);
});