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:
@@ -2,9 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
"glchat/internal/auth"
|
"glchat/internal/auth"
|
||||||
"glchat/internal/bootstrap"
|
"glchat/internal/bootstrap"
|
||||||
@@ -23,6 +27,10 @@ func runCLI(args []string) int {
|
|||||||
return cliResetPassword(args[1:])
|
return cliResetPassword(args[1:])
|
||||||
case "make-admin", "remove-admin":
|
case "make-admin", "remove-admin":
|
||||||
return cliSetInstanceAdmin(args[0], args[1:])
|
return cliSetInstanceAdmin(args[0], args[1:])
|
||||||
|
case "totp-setup":
|
||||||
|
return cliTOTPSetup(args[1:])
|
||||||
|
case "totp-reset":
|
||||||
|
return cliTOTPReset(args[1:])
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
|
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
|
||||||
return 2
|
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 {
|
func cliSetInstanceAdmin(command string, args []string) int {
|
||||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||||
email := flags.String("email", "", "email пользователя")
|
email := flags.String("email", "", "email пользователя")
|
||||||
|
|||||||
+3
-2
@@ -37,10 +37,11 @@ func main() {
|
|||||||
os.Exit(healthcheck())
|
os.Exit(healthcheck())
|
||||||
}
|
}
|
||||||
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
|
// Подкоманды обслуживания (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 {
|
if len(os.Args) > 1 {
|
||||||
switch 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:]))
|
os.Exit(runCLI(os.Args[1:]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ PROFILE="${PROFILE:-auto}"
|
|||||||
INSTANCE_NAME="${INSTANCE_NAME:-glchat}"
|
INSTANCE_NAME="${INSTANCE_NAME:-glchat}"
|
||||||
MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}"
|
MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}"
|
||||||
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
|
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
|
||||||
|
ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}"
|
||||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
||||||
REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}"
|
REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}"
|
||||||
TURN_ENABLED="${TURN_ENABLED:-true}"
|
TURN_ENABLED="${TURN_ENABLED:-true}"
|
||||||
@@ -118,6 +119,9 @@ install.sh — установка glchat (AGENT.md 10).
|
|||||||
--email <email> email для Let's Encrypt (обязателен, если TLS включён)
|
--email <email> email для Let's Encrypt (обязателен, если TLS включён)
|
||||||
--admin <email> логин инстанс-администратора
|
--admin <email> логин инстанс-администратора
|
||||||
--password <password> пароль инстанс-администратора (иначе — интерактивный ввод)
|
--password <password> пароль инстанс-администратора (иначе — интерактивный ввод)
|
||||||
|
--admin-2fa включить 2FA инстанс-админу и напечатать секрет и
|
||||||
|
резервные коды (по умолчанию включается вручную:
|
||||||
|
glchat totp-setup --email <admin>)
|
||||||
--instance-name <name> название инстанса (по умолчанию glchat)
|
--instance-name <name> название инстанса (по умолчанию glchat)
|
||||||
|
|
||||||
Сеть:
|
Сеть:
|
||||||
@@ -183,6 +187,7 @@ parse_args() {
|
|||||||
--email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
--email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||||
--admin) ADMIN_EMAIL="${2:?}"; shift 2 ;;
|
--admin) ADMIN_EMAIL="${2:?}"; shift 2 ;;
|
||||||
--password) ADMIN_PASSWORD="${2:?}"; shift 2 ;;
|
--password) ADMIN_PASSWORD="${2:?}"; shift 2 ;;
|
||||||
|
--admin-2fa) ENABLE_ADMIN_2FA=1; shift ;;
|
||||||
--instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;;
|
--instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;;
|
||||||
--main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;;
|
--main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;;
|
||||||
--ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
|
--ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
|
||||||
@@ -1463,6 +1468,16 @@ bootstrap_admin() {
|
|||||||
else
|
else
|
||||||
warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env"
|
warn "bootstrap-admin недоступен в этой версии образа (появится в фазе 1); данные сохранены в .env"
|
||||||
fi
|
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() {
|
run_smoke() {
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ func newAPIError(err error) apiError {
|
|||||||
case errors.Is(err, auth.ErrTOTPInvalid):
|
case errors.Is(err, auth.ErrTOTPInvalid):
|
||||||
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
|
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
|
||||||
case errors.Is(err, auth.ErrInstanceAdminTOTP):
|
case errors.Is(err, auth.ErrInstanceAdminTOTP):
|
||||||
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required"
|
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required"
|
||||||
candidate.Message = "instance administrators must enable two-factor authentication"
|
candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email <admin>` on the server"
|
||||||
case errors.Is(err, auth.ErrSessionExpired):
|
case errors.Is(err, auth.ErrSessionExpired):
|
||||||
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
|
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
|
||||||
case errors.Is(err, auth.ErrStepUpRequired):
|
case errors.Is(err, auth.ErrStepUpRequired):
|
||||||
|
|||||||
+4
-81
@@ -1,38 +1,11 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router';
|
import { RouterProvider } from 'react-router';
|
||||||
|
|
||||||
import { AuthGuard } from '@/components/AuthGuard';
|
|
||||||
import { LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { router } from '@/router';
|
||||||
import '@/i18n';
|
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() {
|
export function RouteFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full items-center justify-center p-6">
|
<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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<RouteFallback />}>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
|
|||||||
+15
-4
@@ -1,3 +1,5 @@
|
|||||||
|
import { describeUnknown } from '@/lib/http';
|
||||||
|
|
||||||
import type { Channel, Role, UserStatus } from './types';
|
import type { Channel, Role, UserStatus } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,11 +98,16 @@ export interface GatewayConnectionOptions {
|
|||||||
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
|
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
|
||||||
socketFactory?: (url: string) => WebSocket;
|
socketFactory?: (url: string) => WebSocket;
|
||||||
logger?: GatewayLogger;
|
logger?: GatewayLogger;
|
||||||
|
/** Базовая задержка переподключения; в тестах её уменьшают. */
|
||||||
|
baseBackoffMs?: number;
|
||||||
|
/** Сколько ждать HELLO после открытия сокета. */
|
||||||
|
helloTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_MS = 45_000;
|
const DEFAULT_HEARTBEAT_MS = 45_000;
|
||||||
const MAX_BACKOFF_MS = 30_000;
|
const MAX_BACKOFF_MS = 30_000;
|
||||||
const BASE_BACKOFF_MS = 1_000;
|
const BASE_BACKOFF_MS = 1_000;
|
||||||
|
const HELLO_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
interface GatewayPacket {
|
interface GatewayPacket {
|
||||||
op: number;
|
op: number;
|
||||||
@@ -326,6 +333,8 @@ export class GatewayConnection {
|
|||||||
private readonly handlers: GatewayHandlers;
|
private readonly handlers: GatewayHandlers;
|
||||||
private readonly socketFactory: (url: string) => WebSocket;
|
private readonly socketFactory: (url: string) => WebSocket;
|
||||||
private readonly logger: GatewayLogger;
|
private readonly logger: GatewayLogger;
|
||||||
|
private readonly baseBackoffMs: number;
|
||||||
|
private readonly helloTimeoutMs: number;
|
||||||
|
|
||||||
private socket: WebSocket | null = null;
|
private socket: WebSocket | null = null;
|
||||||
private status: GatewayStatus = 'idle';
|
private status: GatewayStatus = 'idle';
|
||||||
@@ -343,6 +352,8 @@ export class GatewayConnection {
|
|||||||
this.url = options.url;
|
this.url = options.url;
|
||||||
this.handlers = options.handlers ?? {};
|
this.handlers = options.handlers ?? {};
|
||||||
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
|
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
|
||||||
|
this.baseBackoffMs = options.baseBackoffMs ?? BASE_BACKOFF_MS;
|
||||||
|
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
|
||||||
this.socketFactory =
|
this.socketFactory =
|
||||||
options.socketFactory ??
|
options.socketFactory ??
|
||||||
((url) => {
|
((url) => {
|
||||||
@@ -403,13 +414,13 @@ export class GatewayConnection {
|
|||||||
try {
|
try {
|
||||||
socket = this.socketFactory(this.url);
|
socket = this.socketFactory(this.url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`gateway: cannot open socket (${String(error)})`);
|
this.logger.warn(`gateway: cannot open socket (${describeUnknown(error)})`);
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
if (reason !== undefined) {
|
if (reason !== undefined) {
|
||||||
this.logger.warn(`gateway: reconnecting (${String(reason)})`);
|
this.logger.warn(`gateway: reconnecting (${describeUnknown(reason)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
@@ -417,7 +428,7 @@ export class GatewayConnection {
|
|||||||
this.helloTimeout = setTimeout(() => {
|
this.helloTimeout = setTimeout(() => {
|
||||||
this.logger.warn('gateway: HELLO timeout');
|
this.logger.warn('gateway: HELLO timeout');
|
||||||
this.dropSocket();
|
this.dropSocket();
|
||||||
}, 15_000);
|
}, this.helloTimeoutMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onmessage = (event: MessageEvent) => {
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
@@ -464,7 +475,7 @@ export class GatewayConnection {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.attempt += 1;
|
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);
|
const jitter = Math.floor(Math.random() * 250);
|
||||||
this.setStatus('reconnecting');
|
this.setStatus('reconnecting');
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Navigate, Outlet, useLocation } from 'react-router';
|
|||||||
|
|
||||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { isUnauthorized } from '@/lib/http';
|
||||||
|
|
||||||
interface AuthGuardProps {
|
interface AuthGuardProps {
|
||||||
/**
|
/**
|
||||||
@@ -45,18 +46,3 @@ export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps)
|
|||||||
|
|
||||||
return <Outlet context={{ user }} />;
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
|
import { LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
|
||||||
|
/** `/` — редирект по состоянию сессии: в приложение или на вход. */
|
||||||
|
export default function IndexRedirect() {
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
if (currentUser.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center p-6">
|
||||||
|
<LoadingNotice />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Navigate to={currentUser.isSuccess ? '/app' : '/login'} replace />;
|
||||||
|
}
|
||||||
@@ -1,50 +1,35 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { completeOnboarding, currentUserQueryKey } from '@/api/users';
|
import { completeOnboarding, currentUserQueryKey } from '@/api/users';
|
||||||
import type { OnboardingInput } 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 { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||||
import { Button, Card } from '@/components/ui/primitives';
|
import { Button, Card } from '@/components/ui/primitives';
|
||||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore, type Theme } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */
|
interface OnboardingFormProps {
|
||||||
export default function OnboardingPage() {
|
/** Имя, предложенное сервером (или из снапшота шлюза). */
|
||||||
|
suggestedName: string;
|
||||||
|
preferredLocale: SupportedLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Форма онбординга: имя, «о себе», язык и тема + POST onboarding/complete. */
|
||||||
|
function OnboardingForm({ suggestedName, preferredLocale }: OnboardingFormProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const currentUser = useCurrentUser();
|
|
||||||
const theme = useUiStore((state) => state.theme);
|
const theme = useUiStore((state) => state.theme);
|
||||||
const setTheme = useUiStore((state) => state.setTheme);
|
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 [bio, setBio] = useState('');
|
||||||
const [locale, setLocale] = useState<SupportedLanguage>(
|
const [locale, setLocale] = useState<SupportedLanguage>(preferredLocale);
|
||||||
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({
|
const complete = useMutation({
|
||||||
mutationFn: (input: OnboardingInput) => completeOnboarding(input),
|
mutationFn: (input: OnboardingInput) => completeOnboarding(input),
|
||||||
@@ -61,13 +46,8 @@ export default function OnboardingPage() {
|
|||||||
void i18n.changeLanguage(language);
|
void i18n.changeLanguage(language);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeTheme = (next: Theme): void => {
|
|
||||||
setTheme(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setTouched(true);
|
|
||||||
const input: OnboardingInput = { locale };
|
const input: OnboardingInput = { locale };
|
||||||
const trimmedName = displayName.trim();
|
const trimmedName = displayName.trim();
|
||||||
const trimmedBio = bio.trim();
|
const trimmedBio = bio.trim();
|
||||||
@@ -80,19 +60,7 @@ export default function OnboardingPage() {
|
|||||||
complete.mutate(input);
|
complete.mutate(input);
|
||||||
};
|
};
|
||||||
|
|
||||||
const unknownUser = currentUser.isError && !complete.isSuccess;
|
|
||||||
|
|
||||||
return (
|
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>
|
<Card>
|
||||||
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||||
<Field
|
<Field
|
||||||
@@ -100,10 +68,7 @@ export default function OnboardingPage() {
|
|||||||
hint={t('onboarding.displayNameHint')}
|
hint={t('onboarding.displayNameHint')}
|
||||||
name="display_name"
|
name="display_name"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(event) => {
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
setTouched(true);
|
|
||||||
setDisplayName(event.target.value);
|
|
||||||
}}
|
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
/>
|
/>
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
@@ -127,14 +92,13 @@ export default function OnboardingPage() {
|
|||||||
<SelectField
|
<SelectField
|
||||||
label={t('onboarding.theme')}
|
label={t('onboarding.theme')}
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(value) => changeTheme(value === 'light' ? 'light' : 'dark')}
|
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'dark', label: t('theme.dark') },
|
{ value: 'dark', label: t('theme.dark') },
|
||||||
{ value: 'light', label: t('theme.light') },
|
{ value: 'light', label: t('theme.light') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{unknownUser ? <ErrorNotice error={currentUser.error} /> : null}
|
|
||||||
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
|
{complete.isError ? <ErrorNotice error={complete.error} /> : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -145,6 +109,41 @@ export default function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<OnboardingForm suggestedName={suggestedName} preferredLocale={preferredLocale} />
|
||||||
|
|
||||||
|
{currentUser.isError ? <ErrorNotice error={currentUser.error} /> : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export default function AppLayout() {
|
|||||||
selectChannel(guildId, channelId);
|
selectChannel(guildId, channelId);
|
||||||
}, [guildId, channelId, selectedGuildId, selectChannel]);
|
}, [guildId, channelId, selectedGuildId, selectChannel]);
|
||||||
|
|
||||||
|
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
|
||||||
|
const realGuildId = guildId === 'empty' ? null : guildId;
|
||||||
|
|
||||||
const myGuilds = useQuery({
|
const myGuilds = useQuery({
|
||||||
queryKey: myGuildsQueryKey,
|
queryKey: myGuildsQueryKey,
|
||||||
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||||
@@ -65,11 +68,11 @@ export default function AppLayout() {
|
|||||||
}
|
}
|
||||||
}, [guilds.length, myGuilds.data, upsertGuild]);
|
}, [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({
|
const fallbackGuild = useQuery({
|
||||||
queryKey: guildQueryKey(guildId ?? ''),
|
queryKey: guildQueryKey(realGuildId ?? ''),
|
||||||
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
|
queryFn: ({ signal }) => fetchGuild(realGuildId ?? '', signal),
|
||||||
enabled: guildId !== null && !guildInSnapshot,
|
enabled: realGuildId !== null && !guildInSnapshot,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
const hydrated = useRef<string | null>(null);
|
const hydrated = useRef<string | null>(null);
|
||||||
|
|||||||
@@ -9,46 +9,16 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
|||||||
import { Field, SelectField } from '@/components/ui/Field';
|
import { Field, SelectField } from '@/components/ui/Field';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { groupChannels } from '@/lib/channels';
|
||||||
import { canManageGuild } from '@/lib/identity';
|
import { canManageGuild } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import type { Channel } from '@/api/types';
|
|
||||||
|
|
||||||
interface ChannelSidebarProps {
|
interface ChannelSidebarProps {
|
||||||
guildId: string | null;
|
guildId: string | null;
|
||||||
channelId: 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) {
|
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -68,7 +38,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
|
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
|
||||||
const [parentId, setParentId] = useState('');
|
const [parentId, setParentId] = useState('');
|
||||||
|
|
||||||
const channels = guild?.channels ?? [];
|
const channels = useMemo(() => guild?.channels ?? [], [guild]);
|
||||||
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
|
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
|
||||||
|
|
||||||
const remoteChannels = useQuery({
|
const remoteChannels = useQuery({
|
||||||
@@ -115,7 +85,10 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const groups = useMemo(() => groupChannels(channels), [channels]);
|
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 =
|
const canManage =
|
||||||
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ленивые страницы приложения. Лежат отдельно от таблицы маршрутов, чтобы
|
||||||
|
* модуль с `routes`/`router` не экспортировал компоненты (react-refresh).
|
||||||
|
*/
|
||||||
|
export const IndexRedirect = lazy(() => import('@/pages/IndexRedirect'));
|
||||||
|
export const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||||
|
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
|
||||||
|
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
||||||
|
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||||
|
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
||||||
|
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
|
||||||
|
export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
|
||||||
|
export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
|
||||||
|
export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage'));
|
||||||
|
export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
|
||||||
|
export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
|
||||||
|
export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
|
||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { SelectField } from '@/components/ui/Field';
|
import { SelectField } from '@/components/ui/Field';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||||
import { useUiStore, type Theme } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
|
/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
|
||||||
export default function AppearanceSettingsPage() {
|
export default function AppearanceSettingsPage() {
|
||||||
@@ -27,7 +27,7 @@ export default function AppearanceSettingsPage() {
|
|||||||
label={t('settings.appearance.theme')}
|
label={t('settings.appearance.theme')}
|
||||||
hint={t('settings.appearance.themeHint')}
|
hint={t('settings.appearance.themeHint')}
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={(value) => setTheme(value === 'light' ? 'light' : ('dark' as Theme))}
|
onChange={(value) => setTheme(value === 'light' ? 'light' : 'dark')}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'dark', label: t('theme.dark') },
|
{ value: 'dark', label: t('theme.dark') },
|
||||||
{ value: 'light', label: t('theme.light') },
|
{ value: 'light', label: t('theme.light') },
|
||||||
|
|||||||
@@ -1,44 +1,33 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
|
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
|
||||||
|
import { userStatuses, type User } from '@/api/types';
|
||||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||||
import { Button, Card } from '@/components/ui/primitives';
|
import { Button, Card } from '@/components/ui/primitives';
|
||||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
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 { t, i18n } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const currentUser = useCurrentUser();
|
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState(user.display_name);
|
||||||
const [bio, setBio] = useState('');
|
const [bio, setBio] = useState(user.bio);
|
||||||
const [customStatus, setCustomStatus] = useState('');
|
const [customStatus, setCustomStatus] = useState(user.custom_status);
|
||||||
const [customEmoji, setCustomEmoji] = useState('');
|
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
|
||||||
const [status, setStatus] = useState<string>('online');
|
const [status, setStatus] = useState<string>(user.status);
|
||||||
const [locale, setLocale] = useState<SupportedLanguage>('ru');
|
const [locale, setLocale] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
|
||||||
const [saved, setSaved] = useState(false);
|
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({
|
const save = useMutation({
|
||||||
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
||||||
onSuccess: (updated) => {
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
|
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
|
||||||
@@ -173,3 +146,34 @@ export default function ProfileSettingsPage() {
|
|||||||
</Card>
|
</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} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,10 @@ import '@testing-library/jest-dom/vitest';
|
|||||||
import { afterEach, vi } from 'vitest';
|
import { afterEach, vi } from 'vitest';
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
|
// Общий экземпляр i18next для всех тестов: без него useTranslation вернёт ключи.
|
||||||
|
import '@/i18n';
|
||||||
|
import { setQueryClient } from '@/lib/queryClient';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
// Globals stubbed with vi.stubGlobal must be dropped between tests,
|
// Globals stubbed with vi.stubGlobal must be dropped between tests,
|
||||||
@@ -9,4 +13,5 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
setQueryClient(null);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user