feat(web): базовый клиент Фазы 1 — вход, серверы, комнаты, настройки

Клиент React 19 + TanStack Query + Zustand:

- вход (поле TOTP появляется на `auth.2fa_required`), регистрация с проверками
  и учётом `registration_enabled`, онбординг первого входа с возможностью
  пропустить, редирект `/` и страница `/status`;
- защита маршрутов: неавторизованных — на `/login`, без онбординга — на
  `/onboarding`;
- оболочка `/app`: рейка серверов, сайдбар категорий и комнат, шапка сервера,
  панель пользователя, модалки создания сервера и комнаты, экран «нет серверов»
  со вступлением в главный сервер, заглушка комнаты до Фазы 2;
- настройки: профиль, безопасность (step-up, сессии, logout-all, 2FA с
  локальным QR и кодами восстановления), внешний вид, админ-раздел инстанса;
- Gateway-клиент: HELLO/IDENTIFY/RESUME, heartbeat с ожиданием ACK,
  экспоненциальное переподключение, разбор `INVALID_SESSION {reason,resumable}`;
- диспетчер событий: READY/RESUMED/USER_UPDATE/GUILD_UPDATE/GUILD_DELETE/
  CHANNEL_* применяются к стору, GUILD_CREATE догружается по REST,
  MEMBER_*/ROLE_* инвалидируют запросы участников, ролей и карточки сервера;
- i18n ru/en, 66 тестов Vitest, `check`/`lint`/`test`/`build` зелёные.

Серверные правки под клиент:

- `totp_enabled` в профиле (`GET /users/@me`, вход, регистрация) — клиенту
  нужно знать, требовать ли код при step-up;
- `POST /auth/2fa/setup` отдаёт `otpauth_url` и `qr_png` (data-URI): QR-код
  рисуется локально, внешние сервисы генерации QR не используются;
- права в ответах — имена (`VIEW_CHANNEL|SEND_MESSAGES`), клиент сверяет имена.
This commit is contained in:
2026-09-19 22:03:46 +03:00
parent d6ca8e420f
commit 9c001fedfe
28 changed files with 1827 additions and 217 deletions
+26 -6
View File
@@ -6,24 +6,26 @@ import { Outlet, useLocation, useNavigate } from 'react-router';
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { instanceQueryKey } from '@/api/instance';
import { GatewayBridge } from '@/components/GatewayBridge';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useCurrentUser, useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
import { GuildRail } from '@/pages/app/GuildRail';
import { UserPanel } from '@/pages/app/UserPanel';
import { errorCode } from '@/lib/format';
/**
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
* пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
* не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
* пользователя. Данные серверов приходят из снапшота шлюза (соединение
* поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся
* REST-запросом `GET /users/@me/guilds`.
*/
export default function AppLayout() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -52,6 +54,21 @@ export default function AppLayout() {
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
const realGuildId = guildId === 'empty' ? null : guildId;
// Сервер появился (вступили в главный или создали свой) — сразу открываем его.
useEffect(() => {
if (realGuildId !== null || guilds.length === 0) {
return;
}
const first = guilds[0];
if (first === undefined) {
return;
}
const channel = useSessionStore.getState().firstChannelId(first.id);
void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, {
replace: true,
});
}, [realGuildId, guilds, navigate]);
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
@@ -129,6 +146,7 @@ export default function AppLayout() {
>
{t('app.skipToContent')}
</a>
<GatewayBridge userId={currentUser.data?.id ?? null} />
<div className="flex min-h-0 flex-1">
<GuildRail
guilds={guildList}
@@ -185,8 +203,10 @@ export default function AppLayout() {
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && errorCode(create.error) === null ? (
<p className="text-xs text-warning">{t('errors.auth.guild_limit_reached')}</p>
{limitReached && !create.isError ? (
<p className="text-xs text-warning" role="status">
{t('guilds.create.limitReached')}
</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
+1
View File
@@ -140,6 +140,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
<NavLink
to={`/app/${guildId}/${channel.id}`}
aria-current={channel.id === channelId ? 'page' : undefined}
aria-label={t('channels.select', { name: channel.name })}
title={channel.name}
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
channel.id === channelId
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currentUserQueryKey, fetchCurrentUser } from '@/api/users';
import { PasswordSection } from '@/pages/settings/PasswordSection';
import { SessionsSection } from '@/pages/settings/SessionsSection';
import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
@@ -9,13 +10,16 @@ import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
* Безопасность: смена пароля со step-up, список сессий с выходом на всех
* устройствах и включение 2FA с показом секрета и кодов восстановления.
*
* Контракт Фазы 1 не отдаёт флаг включённой 2FA в `GET /users/@me`, поэтому
* состояние «2FA включена» ведём локально: после успешного `2fa/enable`
* формы запрашивают код подтверждения.
* Состояние 2FA берём из профиля (`totp_enabled`), обновляя запрос после
* включения: от него зависит, требовать ли код при step-up.
*/
export default function SecuritySettingsPage() {
const { t } = useTranslation();
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
const currentUser = useQuery({
queryKey: currentUserQueryKey,
queryFn: ({ signal }) => fetchCurrentUser(signal),
});
const twoFactorEnabled = currentUser.data?.totp_enabled === true;
return (
<div className="flex flex-col gap-5">
@@ -25,7 +29,12 @@ export default function SecuritySettingsPage() {
</header>
<PasswordSection twoFactorEnabled={twoFactorEnabled} />
<SessionsSection />
<TwoFactorSection enabled={twoFactorEnabled} onEnabled={() => setTwoFactorEnabled(true)} />
<TwoFactorSection
enabled={twoFactorEnabled}
onEnabled={() => {
void currentUser.refetch();
}}
/>
</div>
);
}
+4 -4
View File
@@ -21,6 +21,7 @@ export function TwoFactorSection({
const { t } = useTranslation();
const [secret, setSecret] = useState<string | null>(null);
const [otpauthUrl, setOtpauthUrl] = useState('');
const [qrPng, setQrPng] = useState('');
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [qrFailed, setQrFailed] = useState(false);
@@ -31,6 +32,7 @@ export function TwoFactorSection({
onSuccess: (payload) => {
setSecret(payload.secret);
setOtpauthUrl(payload.otpauth_url);
setQrPng(payload.qr_png ?? '');
setRecoveryCodes(null);
setQrFailed(false);
},
@@ -81,15 +83,13 @@ export function TwoFactorSection({
<p className="text-sm text-fg-muted">{t('settings.security.twoFactorSetupHint')}</p>
<div className="flex flex-wrap items-start gap-4">
{qrFailed || otpauthUrl === '' ? (
{qrFailed || qrPng === '' ? (
<p className="text-xs text-fg-muted">
{t('settings.security.twoFactorQrUnavailable')}
</p>
) : (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(
otpauthUrl,
)}`}
src={qrPng}
alt={t('settings.security.twoFactorQrAlt')}
width={160}
height={160}