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