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:
+4
-81
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user