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
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { enableTotp, setupTotp } from '@/api/auth';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
/**
* Включение двухфакторной аутентификации: setup → секрет (и QR) → код →
* одноразовые коды восстановления, которые показываются ровно один раз.
*/
export function TwoFactorSection({
enabled,
onEnabled,
}: {
enabled: boolean;
onEnabled: () => void;
}) {
const { t } = useTranslation();
const [secret, setSecret] = useState<string | null>(null);
const [otpauthUrl, setOtpauthUrl] = useState('');
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [qrFailed, setQrFailed] = useState(false);
const [copied, setCopied] = useState(false);
const setup = useMutation({
mutationFn: () => setupTotp(),
onSuccess: (payload) => {
setSecret(payload.secret);
setOtpauthUrl(payload.otpauth_url);
setRecoveryCodes(null);
setQrFailed(false);
},
});
const enable = useMutation({
mutationFn: () => enableTotp(code.trim()),
onSuccess: (codes) => {
setRecoveryCodes(codes);
setSecret(null);
setCode('');
onEnabled();
},
});
const copyRecovery = (): void => {
if (recoveryCodes === null) {
return;
}
void navigator.clipboard?.writeText(recoveryCodes.join('\n'));
setCopied(true);
};
return (
<Card>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">{t('settings.security.twoFactorTitle')}</h2>
<p className="mt-1 text-sm text-fg-muted">
{t(
enabled
? 'settings.security.twoFactorEnabled'
: 'settings.security.twoFactorDisabled',
)}
</p>
</div>
{enabled || secret !== null ? null : (
<Button disabled={setup.isPending} onClick={() => setup.mutate()}>
{t('settings.security.twoFactorEnable')}
</Button>
)}
</div>
{setup.isError ? <ErrorNotice className="mt-3" error={setup.error} /> : null}
{secret !== null ? (
<div className="mt-4 flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3">
<p className="text-sm text-fg-muted">{t('settings.security.twoFactorSetupHint')}</p>
<div className="flex flex-wrap items-start gap-4">
{qrFailed || otpauthUrl === '' ? (
<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,
)}`}
alt={t('settings.security.twoFactorQrAlt')}
width={160}
height={160}
className="rounded-[var(--radius-sm)] bg-white p-1"
onError={() => setQrFailed(true)}
/>
)}
<dl className="text-sm">
<dt className="text-fg-muted">{t('settings.security.twoFactorSecret')}</dt>
<dd className="mt-1 font-mono text-xs break-all" data-testid="totp-secret">
{secret}
</dd>
<dt className="mt-3 text-fg-muted">{t('settings.security.twoFactorUri')}</dt>
<dd className="mt-1 font-mono text-xs break-all">{otpauthUrl}</dd>
</dl>
</div>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (code.trim() !== '') {
enable.mutate();
}
}}
>
<Field
label={t('settings.security.twoFactorCode')}
name="totp_code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={8}
value={code}
onChange={(event) => setCode(event.target.value)}
required
/>
{enable.isError ? <ErrorNotice error={enable.error} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={enable.isPending || code.trim() === ''}>
{t(enable.isPending ? 'common.saving' : 'settings.security.twoFactorConfirm')}
</Button>
</div>
</form>
</div>
) : null}
{recoveryCodes !== null ? (
<div className="mt-4 rounded-[var(--radius-md)] border border-success/50 bg-success/10 p-3">
<p className="text-sm text-success" role="status">
{t('settings.security.twoFactorEnabledDone')}
</p>
<h3 className="mt-2 text-sm font-semibold">{t('settings.security.recoveryTitle')}</h3>
<p className="text-xs text-fg-muted">{t('settings.security.recoveryHint')}</p>
<ul
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
data-testid="recovery-codes"
>
{recoveryCodes.map((recoveryCode) => (
<li key={recoveryCode}>{recoveryCode}</li>
))}
</ul>
<div className="mt-3 flex items-center gap-3">
<Button variant="ghost" onClick={copyRecovery}>
{t(copied ? 'common.copied' : 'settings.security.recoveryCopyAll')}
</Button>
</div>
</div>
) : null}
</Card>
);
}