feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса

REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+62
View File
@@ -0,0 +1,62 @@
import { Navigate, Outlet, useLocation } from 'react-router';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { useCurrentUser } from '@/lib/hooks';
interface AuthGuardProps {
/**
* Если `true`, пользователей с незавершённым онбордингом пускаем внутрь
* (нужно для /onboarding и /settings).
*/
allowIncompleteOnboarding?: boolean;
}
/**
* Защита маршрутов: проверяет GET /users/@me.
* Неавторизованных отправляет на /login, не прошедших онбординг — на /onboarding.
*/
export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps) {
const location = useLocation();
const currentUser = useCurrentUser();
if (currentUser.isPending) {
return (
<div className="flex min-h-full items-center justify-center p-6">
<LoadingNotice />
</div>
);
}
if (currentUser.isError) {
if (isUnauthorized(currentUser.error)) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return (
<div className="mx-auto flex min-h-full w-full max-w-md flex-col justify-center p-6">
<ErrorNotice error={currentUser.error} onRetry={() => void currentUser.refetch()} />
</div>
);
}
const user = currentUser.data;
if (!allowIncompleteOnboarding && !user.onboarding_completed) {
return <Navigate to="/onboarding" replace />;
}
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);
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { useGatewayStore } from '@/stores/gateway';
/**
* Поднимает соединение со шлюзом, когда известно, кто вошёл в систему.
* Живёт в корне приложения и переживает переходы между страницами:
* состояние подключения хранится в zustand-сторе, а не в компоненте.
*/
export function GatewayBridge({ userId }: { userId: string | null }) {
const status = useGatewayStore((state) => state.status);
const connect = useGatewayStore((state) => state.connect);
const disconnect = useGatewayStore((state) => state.disconnect);
useEffect(() => {
if (userId === null) {
if (status === 'idle') {
return;
}
disconnect();
return;
}
if (status !== 'idle') {
return;
}
connect(userId);
}, [userId, status, connect, disconnect]);
return null;
}
+38
View File
@@ -0,0 +1,38 @@
import { avatarUrl, hueFromId, initials } from '@/lib/identity';
interface AvatarProps {
name: string;
/** Стабильный id для оттенка заглушки. */
seed: string;
fileId?: string | undefined;
size?: 'sm' | 'md' | 'lg';
/** Квадратная заглушка для серверов, круглая — для пользователей. */
shape?: 'circle' | 'square';
}
const sizes = {
sm: 'h-7 w-7 text-[11px]',
md: 'h-9 w-9 text-xs',
lg: 'h-14 w-14 text-lg',
} as const;
/** Аватар: картинка файлового сервиса либо инициалы на цветной заглушке. */
export function Avatar({ name, seed, fileId, size = 'md', shape = 'circle' }: AvatarProps) {
const src = avatarUrl(fileId);
const hue = hueFromId(seed);
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-md)]';
return (
<span
aria-hidden="true"
className={`inline-flex shrink-0 items-center justify-center overflow-hidden font-semibold text-white ${radius} ${sizes[size]}`}
style={src === null ? { backgroundColor: `oklch(0.55 0.13 ${String(hue)})` } : undefined}
>
{src === null ? (
initials(name)
) : (
<img src={src} alt="" className="h-full w-full object-cover" />
)}
</span>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/primitives';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
interface ErrorNoticeProps {
error: unknown;
onRetry?: () => void;
className?: string;
}
/** Сообщение об ошибке API с необязательной кнопкой повтора. */
export function ErrorNotice({ error, onRetry, className = '' }: ErrorNoticeProps) {
const { t } = useTranslation();
const describe = useApiErrorMessage();
return (
<div
role="alert"
className={`flex items-start justify-between gap-3 rounded-[var(--radius-md)] border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger ${className}`}
>
<p>{describe(error)}</p>
{onRetry === undefined ? null : (
<Button variant="ghost" onClick={onRetry}>
{t('common.retry')}
</Button>
)}
</div>
);
}
/** Компактная строка состояния загрузки. */
export function LoadingNotice({ label }: { label?: string }) {
const { t } = useTranslation();
return (
<p className="text-sm text-fg-muted" role="status">
{label ?? t('common.loading')}
</p>
);
}
+128
View File
@@ -0,0 +1,128 @@
import {
useId,
type InputHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
} from 'react';
interface FieldShellProps {
label: string;
hint?: string | undefined;
error?: string | null | undefined;
children: (ids: { id: string; describedBy: string | undefined }) => ReactNode;
}
/** Общая обёртка поля: label, подсказка и сообщение об ошибке. */
function FieldShell({ label, hint, error, children }: FieldShellProps) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy =
[hint === undefined ? null : hintId, error === null || error === undefined ? null : errorId]
.filter((value): value is string => value !== null)
.join(' ') || undefined;
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-fg" htmlFor={id}>
{label}
</label>
{children({ id, describedBy })}
{hint === undefined ? null : (
<p id={hintId} className="text-xs text-fg-muted">
{hint}
</p>
)}
{error === null || error === undefined ? null : (
<p id={errorId} className="text-xs text-danger" role="alert">
{error}
</p>
)}
</div>
);
}
const controlClass =
'w-full rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2 text-fg placeholder:text-fg-muted/70 disabled:opacity-60';
export type InputFieldProps = {
label: string;
hint?: string | undefined;
error?: string | null | undefined;
} & InputHTMLAttributes<HTMLInputElement>;
export function Field({ label, hint, error, className = '', ...rest }: InputFieldProps) {
return (
<FieldShell label={label} hint={hint} error={error}>
{({ id, describedBy }) => (
<input
id={id}
aria-describedby={describedBy}
aria-invalid={error === null || error === undefined ? undefined : true}
className={`${controlClass} ${className}`}
{...rest}
/>
)}
</FieldShell>
);
}
export type TextAreaFieldProps = {
label: string;
hint?: string | undefined;
error?: string | null | undefined;
} & TextareaHTMLAttributes<HTMLTextAreaElement>;
export function TextAreaField({ label, hint, error, className = '', ...rest }: TextAreaFieldProps) {
return (
<FieldShell label={label} hint={hint} error={error}>
{({ id, describedBy }) => (
<textarea
id={id}
aria-describedby={describedBy}
aria-invalid={error === null || error === undefined ? undefined : true}
className={`${controlClass} ${className}`}
{...rest}
/>
)}
</FieldShell>
);
}
interface SelectFieldProps {
label: string;
hint?: string | undefined;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
className?: string;
}
export function SelectField({
label,
hint,
value,
onChange,
options,
className = '',
}: SelectFieldProps) {
return (
<FieldShell label={label} hint={hint}>
{({ id, describedBy }) => (
<select
id={id}
aria-describedby={describedBy}
className={`${controlClass} ${className}`}
value={value}
onChange={(event) => onChange(event.target.value)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
</FieldShell>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface ModalProps {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
/**
* Простое модальное окно: закрывается по Escape и по клику на подложку,
* focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса.
*/
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', onKeyDown);
const firstField = panelRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button',
);
firstField?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [open, onClose]);
if (!open) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)]"
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
type="button"
aria-label={t('common.close')}
className="rounded-[var(--radius-sm)] px-2 text-lg leading-none text-fg-muted hover:text-fg"
onClick={onClose}
>
×
</button>
</div>
<div className="mt-4">{children}</div>
{footer === undefined ? null : <div className="mt-5 flex justify-end gap-2">{footer}</div>}
</div>
</div>
);
}