feat(instance): глобальный бан пользователя и поиск в админ-панели

Сервер:
- миграция 00017: таблица instance_bans (причина, автор, дата);
- auth: ErrUserBanned, проверка бана в Login (код user.banned, 403) и в
  ResolveSession — забаненный не получает сессию ни по cookie, ни по Bearer,
  ни в Gateway, а прежняя сессия удаляется;
- store: BannedAt/BanReason в модели пользователя, BanInstanceUser,
  UnbanInstanceUser, IsInstanceBanned, поиск и фильтр в ListUsers,
  CountUsersFiltered; мягкое удаление аккаунта убирает и запись о бане;
- API: POST /instance/users/{id}/ban и /unban со step-up (AGENT.md 7.1),
  отзыв сессий и SESSION_INVALIDATED, аудит instance.user_ban с причиной и
  instance.user_unban; себя и инстанс-админа забанить нельзя;
- GET /instance/users: q (логин и отображаемое имя), banned=true, total.

Клиент:
- панель: поиск, фильтр «только забаненные», бейдж бана с причиной, кнопки
  «Забанить» (с причиной) и «Разбанить» через общий шаг подтверждения
  личности; i18n ru/en, включая текст ошибки user.banned;
- keepPreviousData в списке пользователей: без этого поле поиска
  размонтировалось на первом же символе и набор обрывался.

Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
This commit is contained in:
2026-09-22 18:59:53 +03:00
parent d2712c4c99
commit 787f822dc0
15 changed files with 850 additions and 55 deletions
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { deleteUser, instanceUsersQueryKey, logoutUser, resetUserPassword } from '@/api/instance';
import type { InstanceUser } from '@/api/types';
import { Button } from '@/components/ui/primitives';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
@@ -11,10 +12,10 @@ import { useCurrentUser } from '@/lib/hooks';
import { useEffect } from 'react';
interface InstanceUsersPanelProps {
/** Свой id: себя удалять нельзя (сервер тоже это проверяет). */
/** Свой id: себя удалять и банить нельзя (сервер тоже это проверяет). */
currentUserId: string | undefined;
/** Признак, что список загружен: панель показывает действия. */
users: { id: string; username: string; display_name: string; is_instance_admin: boolean }[];
users: InstanceUser[];
isPending: boolean;
error: unknown;
onRetry: () => void;
@@ -23,8 +24,24 @@ interface InstanceUsersPanelProps {
adminPending: boolean;
/** Ошибка смены прав: сервер требует step-up или отказывает. */
adminError?: unknown;
/** Глобальный бан и разбан (AGENT.md 7.18). */
onBan: (userId: string, reason: string) => void;
onUnban: (userId: string) => void;
banPending: boolean;
banError?: unknown;
/** Поиск по логину и имени и фильтр «только забаненные». */
search: string;
onSearchChange: (value: string) => void;
bannedOnly: boolean;
onBannedOnlyChange: (value: boolean) => void;
}
/** Действие, для которого запрашивается подтверждение личности. */
type StepUpAction =
| { kind: 'admin'; userId: string; makeAdmin: boolean }
| { kind: 'ban'; userId: string; reason: string }
| { kind: 'unban'; userId: string };
/**
* Действия администратора инстанса над пользователем (AGENT.md 7.19):
* временный пароль, выход со всех устройств и мягкое удаление.
@@ -38,6 +55,14 @@ export function InstanceUsersPanel({
onToggleAdmin,
adminPending,
adminError,
onBan,
onUnban,
banPending,
banError,
search,
onSearchChange,
bannedOnly,
onBannedOnlyChange,
}: InstanceUsersPanelProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -45,8 +70,10 @@ export function InstanceUsersPanel({
const [password, setPassword] = useState<{ userId: string; value: string } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const [notice, setNotice] = useState<string | null>(null);
// Смена администраторов требует подтверждения личности (AGENT.md 7.1).
const [stepUpFor, setStepUpFor] = useState<{ userId: string; makeAdmin: boolean } | null>(null);
// Чувствительные действия требуют подтверждения личности (AGENT.md 7.1).
const [stepUpFor, setStepUpFor] = useState<StepUpAction | null>(null);
// Причина бана вводится до подтверждения личности.
const [banFor, setBanFor] = useState<{ userId: string; reason: string } | null>(null);
const [stepUpPassword, setStepUpPassword] = useState('');
const [stepUpCode, setStepUpCode] = useState('');
@@ -56,9 +83,17 @@ export function InstanceUsersPanel({
const target = stepUpFor;
setStepUpPassword('');
setStepUpCode('');
if (target !== null) {
onToggleAdmin(target.userId, target.makeAdmin);
if (target === null) {
return;
}
if (target.kind === 'admin') {
onToggleAdmin(target.userId, target.makeAdmin);
} else if (target.kind === 'ban') {
onBan(target.userId, target.reason);
} else {
onUnban(target.userId);
}
setStepUpFor(null);
},
});
@@ -103,7 +138,8 @@ export function InstanceUsersPanel({
onError: (cause: unknown) => setActionError(cause),
});
const pending = reset.isPending || logout.isPending || remove.isPending || adminPending;
const pending =
reset.isPending || logout.isPending || remove.isPending || adminPending || banPending;
if (isPending) {
return <p className="mt-2 text-sm text-fg-muted">{t('common.loading')}</p>;
@@ -119,6 +155,7 @@ export function InstanceUsersPanel({
<div className="mt-3 flex flex-col gap-2" data-testid="instance-users-actions">
{actionError === null ? null : <ErrorNotice error={actionError} />}
{adminError === null || adminError === undefined ? null : <ErrorNotice error={adminError} />}
{banError === null || banError === undefined ? null : <ErrorNotice error={banError} />}
{notice === null ? null : (
<p className="text-sm text-success" role="status">
{notice}
@@ -172,6 +209,25 @@ export function InstanceUsersPanel({
</p>
)}
<div className="flex flex-wrap items-end gap-3">
<Field
label={t('settings.instance.search.label')}
name="instance-user-search"
value={search}
placeholder={t('settings.instance.search.placeholder')}
onChange={(event) => onSearchChange(event.target.value)}
/>
<label className="flex items-center gap-2 pb-1 text-sm">
<input
type="checkbox"
data-testid="instance-users-banned-only"
checked={bannedOnly}
onChange={(event) => onBannedOnlyChange(event.target.checked)}
/>
{t('settings.instance.search.bannedOnly')}
</label>
</div>
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-users">
{users.map((user) => {
const self = user.id === currentUserId;
@@ -195,6 +251,45 @@ export function InstanceUsersPanel({
{t('settings.instance.adminBadge')}
</span>
) : null}
{user.banned ? (
<span
className="shrink-0 rounded-full border border-danger/60 px-2 text-xs text-danger"
title={user.ban_reason === undefined ? undefined : user.ban_reason}
data-testid={`instance-user-banned-${user.id}`}
>
{t('settings.instance.bannedBadge')}
</span>
) : null}
{banFor !== null && banFor.userId === user.id ? (
<form
className="flex w-full flex-wrap items-end gap-2"
data-testid={`instance-user-ban-form-${user.id}`}
onSubmit={(event) => {
event.preventDefault();
setStepUpFor({ kind: 'ban', userId: user.id, reason: banFor.reason });
setBanFor(null);
}}
>
<Field
label={t('settings.instance.actions.banReason')}
name="ban_reason"
maxLength={400}
value={banFor.reason}
onChange={(event) => setBanFor({ userId: user.id, reason: event.target.value })}
/>
<Button
type="submit"
variant="ghost"
className="border border-danger/50 text-danger"
>
{t('settings.instance.actions.banSubmit')}
</Button>
<Button variant="ghost" onClick={() => setBanFor(null)}>
{t('common.cancel')}
</Button>
</form>
) : null}
<Button
variant="ghost"
@@ -204,7 +299,11 @@ export function InstanceUsersPanel({
setActionError(null);
onToggleAdmin(user.id, !user.is_instance_admin);
// Подтверждение покажем, если сервер его потребует.
setStepUpFor({ userId: user.id, makeAdmin: !user.is_instance_admin });
setStepUpFor({
kind: 'admin',
userId: user.id,
makeAdmin: !user.is_instance_admin,
});
}}
>
{t(
@@ -213,6 +312,25 @@ export function InstanceUsersPanel({
: 'settings.instance.actions.makeAdmin',
)}
</Button>
{/* Инстанс-админа и себя банить нельзя (AGENT.md 7.19). */}
<Button
variant="ghost"
className={user.banned ? undefined : 'border border-danger/50 text-danger'}
data-testid={`instance-user-ban-${user.id}`}
disabled={pending || self || user.is_instance_admin}
onClick={() => {
setActionError(null);
if (user.banned) {
setStepUpFor({ kind: 'unban', userId: user.id });
return;
}
setBanFor({ userId: user.id, reason: '' });
}}
>
{t(
user.banned ? 'settings.instance.actions.unban' : 'settings.instance.actions.ban',
)}
</Button>
<Button
variant="ghost"
data-testid={`instance-user-reset-${user.id}`}