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:
+51
-4
@@ -57,12 +57,59 @@ export async function fetchInstanceGuilds(signal?: AbortSignal): Promise<Instanc
|
||||
return payload.guilds;
|
||||
}
|
||||
|
||||
export async function fetchInstanceUsers(signal?: AbortSignal): Promise<InstanceUser[]> {
|
||||
const payload = await request<{ users: InstanceUser[] }>(
|
||||
'/instance/users',
|
||||
export interface InstanceUserFilter {
|
||||
/** Поиск по логину и отображаемому имени. */
|
||||
query?: string;
|
||||
/** Показать только забаненных. */
|
||||
bannedOnly?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** Ключ списка пользователей: фильтр входит в ключ, иначе кэш смешает выборки. */
|
||||
export function instanceUserListQueryKey(filter: InstanceUserFilter = {}) {
|
||||
return [...instanceUsersQueryKey, filter.query ?? '', filter.bannedOnly === true] as const;
|
||||
}
|
||||
|
||||
export async function fetchInstanceUsers(
|
||||
filter: InstanceUserFilter = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ users: InstanceUser[]; total: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.query !== undefined && filter.query !== '') {
|
||||
params.set('q', filter.query);
|
||||
}
|
||||
if (filter.bannedOnly === true) {
|
||||
params.set('banned', 'true');
|
||||
}
|
||||
if (filter.limit !== undefined) {
|
||||
params.set('limit', String(filter.limit));
|
||||
}
|
||||
if (filter.offset !== undefined) {
|
||||
params.set('offset', String(filter.offset));
|
||||
}
|
||||
const suffix = params.size === 0 ? '' : `?${params.toString()}`;
|
||||
const payload = await request<{ users: InstanceUser[]; total: number }>(
|
||||
`/instance/users${suffix}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.users;
|
||||
return { users: payload.users, total: payload.total };
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /instance/users/{id}/ban` — глобальный бан инстанса (AGENT.md 7.18):
|
||||
* аккаунт не может войти, все сессии отзываются.
|
||||
*/
|
||||
export async function banUser(userId: string, reason: string): Promise<void> {
|
||||
await request(`/instance/users/${encodeURIComponent(userId)}/ban`, {
|
||||
method: 'POST',
|
||||
body: { reason },
|
||||
});
|
||||
}
|
||||
|
||||
/** `POST /instance/users/{id}/unban` — снять глобальный бан. */
|
||||
export async function unbanUser(userId: string): Promise<void> {
|
||||
await request(`/instance/users/${encodeURIComponent(userId)}/unban`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<AuditEntry[]> {
|
||||
|
||||
@@ -189,6 +189,10 @@ export interface InstanceUser {
|
||||
display_name: string;
|
||||
is_instance_admin: boolean;
|
||||
created_at: string;
|
||||
/** Глобальный бан инстанса (AGENT.md 7.18). */
|
||||
banned: boolean;
|
||||
banned_at?: string;
|
||||
ban_reason?: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
"limit_reached": "The limit of sounds of this kind is reached.",
|
||||
"event_required": "Choose an event for a palette sound."
|
||||
},
|
||||
"voice.not_connected": "Join a voice channel first."
|
||||
"voice.not_connected": "Join a voice channel first.",
|
||||
"user.banned": "This account is banned on the instance. Contact the administrator."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Light theme",
|
||||
@@ -872,7 +873,19 @@
|
||||
"openSettings": "Settings",
|
||||
"rename": "Rename",
|
||||
"newName": "New server name",
|
||||
"deleteGuildConfirm": "Delete server “{{name}}” with all its channels?"
|
||||
"deleteGuildConfirm": "Delete server “{{name}}” with all its channels?",
|
||||
"ban": "Ban",
|
||||
"unban": "Unban",
|
||||
"banReason": "Ban reason",
|
||||
"banSubmit": "Ban",
|
||||
"banned": "User is banned on this instance, sessions revoked.",
|
||||
"unbanned": "Ban lifted."
|
||||
},
|
||||
"bannedBadge": "Banned",
|
||||
"search": {
|
||||
"label": "Search",
|
||||
"placeholder": "Username or name",
|
||||
"bannedOnly": "Banned only"
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
"limit_reached": "Достигнут лимит звуков этого вида.",
|
||||
"event_required": "Для звука палитры выберите событие."
|
||||
},
|
||||
"voice.not_connected": "Сначала войдите в голосовую комнату."
|
||||
"voice.not_connected": "Сначала войдите в голосовую комнату.",
|
||||
"user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Светлая тема",
|
||||
@@ -872,7 +873,19 @@
|
||||
"openSettings": "Настройки",
|
||||
"rename": "Переименовать",
|
||||
"newName": "Новое имя сервера",
|
||||
"deleteGuildConfirm": "Удалить сервер «{{name}}» вместе со всеми комнатами?"
|
||||
"deleteGuildConfirm": "Удалить сервер «{{name}}» вместе со всеми комнатами?",
|
||||
"ban": "Забанить",
|
||||
"unban": "Разбанить",
|
||||
"banReason": "Причина бана",
|
||||
"banSubmit": "Забанить",
|
||||
"banned": "Пользователь забанен на инстансе, сессии отозваны.",
|
||||
"unbanned": "Бан снят."
|
||||
},
|
||||
"bannedBadge": "Забанен",
|
||||
"search": {
|
||||
"label": "Поиск",
|
||||
"placeholder": "Логин или имя",
|
||||
"bannedOnly": "Только забаненные"
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
banUser,
|
||||
fetchAudit,
|
||||
fetchInstanceGuilds,
|
||||
fetchInstanceSettings,
|
||||
@@ -10,8 +11,10 @@ import {
|
||||
instanceAuditQueryKey,
|
||||
instanceGuildsQueryKey,
|
||||
instanceSettingsQueryKey,
|
||||
instanceUserListQueryKey,
|
||||
instanceUsersQueryKey,
|
||||
setInstanceAdmin,
|
||||
unbanUser,
|
||||
type InstanceSettings,
|
||||
} from '@/api/instance';
|
||||
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
||||
@@ -43,6 +46,9 @@ export default function InstanceSettingsPage() {
|
||||
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||
const openSettings = useUiStore((state) => state.openSettings);
|
||||
const [tab, setTab] = useState<InstanceTab>('overview');
|
||||
// Поиск и фильтр пользователей панели (AGENT.md 7.18).
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [bannedOnly, setBannedOnly] = useState(false);
|
||||
|
||||
const settings = useQuery({
|
||||
queryKey: instanceSettingsQueryKey,
|
||||
@@ -56,11 +62,15 @@ export default function InstanceSettingsPage() {
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
});
|
||||
const userFilter = { query: userSearch.trim(), bannedOnly };
|
||||
const users = useQuery({
|
||||
queryKey: instanceUsersQueryKey,
|
||||
queryFn: ({ signal }) => fetchInstanceUsers(signal),
|
||||
queryKey: instanceUserListQueryKey(userFilter),
|
||||
queryFn: ({ signal }) => fetchInstanceUsers(userFilter, signal),
|
||||
enabled: isAdmin,
|
||||
retry: 0,
|
||||
// Поиск не должен подменять список надписью «загрузка»: иначе поле ввода
|
||||
// исчезает на каждом символе и набор обрывается.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const audit = useQuery({
|
||||
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
|
||||
@@ -71,13 +81,23 @@ export default function InstanceSettingsPage() {
|
||||
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
||||
const metrics = useInstanceMetrics(isAdmin);
|
||||
|
||||
const afterUserChange = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
|
||||
};
|
||||
|
||||
const admin = useMutation({
|
||||
mutationFn: (input: { userId: string; makeAdmin: boolean }) =>
|
||||
setInstanceAdmin(input.userId, input.makeAdmin),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
|
||||
},
|
||||
onSuccess: afterUserChange,
|
||||
});
|
||||
const ban = useMutation({
|
||||
mutationFn: (input: { userId: string; reason: string }) => banUser(input.userId, input.reason),
|
||||
onSuccess: afterUserChange,
|
||||
});
|
||||
const unban = useMutation({
|
||||
mutationFn: (userId: string) => unbanUser(userId),
|
||||
onSuccess: afterUserChange,
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
@@ -92,7 +112,7 @@ export default function InstanceSettingsPage() {
|
||||
const tabLabels: Record<InstanceTab, string> = {
|
||||
overview: t('settings.instance.tabs.overview'),
|
||||
limits: t('settings.instance.tabs.limits'),
|
||||
users: t('settings.instance.tabs.users', { count: users.data?.length ?? 0 }),
|
||||
users: t('settings.instance.tabs.users', { count: users.data?.total ?? 0 }),
|
||||
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.length ?? 0 }),
|
||||
audit: t('settings.instance.tabs.audit'),
|
||||
};
|
||||
@@ -168,13 +188,21 @@ export default function InstanceSettingsPage() {
|
||||
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
|
||||
<InstanceUsersPanel
|
||||
currentUserId={currentUser.data?.id}
|
||||
users={users.data ?? []}
|
||||
users={users.data?.users ?? []}
|
||||
isPending={users.isPending}
|
||||
error={users.error}
|
||||
onRetry={() => void users.refetch()}
|
||||
onToggleAdmin={(userId, makeAdmin) => admin.mutate({ userId, makeAdmin })}
|
||||
adminPending={admin.isPending}
|
||||
adminError={admin.error}
|
||||
onBan={(userId, reason) => ban.mutate({ userId, reason })}
|
||||
onUnban={(userId) => unban.mutate(userId)}
|
||||
banPending={ban.isPending || unban.isPending}
|
||||
banError={ban.error ?? unban.error}
|
||||
search={userSearch}
|
||||
onSearchChange={setUserSearch}
|
||||
bannedOnly={bannedOnly}
|
||||
onBannedOnlyChange={setBannedOnly}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
@@ -530,8 +530,10 @@ describe('настройки: аватар', () => {
|
||||
display_name: 'Alice',
|
||||
is_instance_admin: true,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
banned: false,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||
@@ -547,6 +549,87 @@ describe('настройки: аватар', () => {
|
||||
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
|
||||
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('глобальный бан требует причину и step-up, разбан возвращает доступ', async () => {
|
||||
const other = {
|
||||
id: 'u-7',
|
||||
username: 'bob',
|
||||
display_name: 'Bob',
|
||||
is_instance_admin: false,
|
||||
created_at: '2026-09-02T00:00:00Z',
|
||||
banned: false,
|
||||
};
|
||||
const bannedUser = {
|
||||
...other,
|
||||
id: 'u-8',
|
||||
username: 'mallory',
|
||||
display_name: 'Mallory',
|
||||
banned: true,
|
||||
ban_reason: 'спам',
|
||||
};
|
||||
const banRequests: { url: string; body: unknown }[] = [];
|
||||
const listUrls: string[] = [];
|
||||
const fetchMock = installFetch(
|
||||
appRoutes(makeUser({ is_instance_admin: true }), [
|
||||
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||
{
|
||||
match: '/api/v1/auth/step-up',
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/instance/users/u-7/ban',
|
||||
method: 'POST',
|
||||
response: (request) => {
|
||||
banRequests.push({ url: request.url, body: request.body });
|
||||
return json({ user: {} });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: '/api/v1/instance/users',
|
||||
response: (request) => {
|
||||
listUrls.push(request.url);
|
||||
return json({ users: [other, bannedUser], total: 2 });
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
renderApp('/app/empty');
|
||||
const dialog = await openSettings();
|
||||
await goToSection(dialog, 'Инстанс');
|
||||
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
|
||||
|
||||
// Забаненный помечен, у админа и себя кнопка бана недоступна.
|
||||
expect(await within(dialog).findByTestId('instance-user-banned-u-8')).toHaveTextContent(
|
||||
'Забанен',
|
||||
);
|
||||
expect(within(dialog).getByTestId('instance-user-ban-u-7')).toBeEnabled();
|
||||
|
||||
// Бан: сначала причина, затем подтверждение личности.
|
||||
await userEvent.click(within(dialog).getByTestId('instance-user-ban-u-7'));
|
||||
const reasonForm = await within(dialog).findByTestId('instance-user-ban-form-u-7');
|
||||
await userEvent.type(within(reasonForm).getByLabelText('Причина бана'), 'флуд');
|
||||
await userEvent.click(within(reasonForm).getByRole('button', { name: 'Забанить' }));
|
||||
|
||||
const stepUpForm = await within(dialog).findByTestId('instance-step-up');
|
||||
await userEvent.type(within(stepUpForm).getByLabelText('Ваш пароль'), 'correct-horse-battery');
|
||||
await userEvent.click(within(stepUpForm).getByRole('button', { name: 'Подтвердить' }));
|
||||
|
||||
await waitFor(() => expect(banRequests).toHaveLength(1));
|
||||
expect(banRequests[0]?.url).toContain('/instance/users/u-7/ban');
|
||||
expect(banRequests[0]?.body).toEqual({ reason: 'флуд' });
|
||||
|
||||
// Поиск уходит на сервер параметром q, фильтр — banned=true.
|
||||
const search = within(dialog).getByLabelText('Поиск');
|
||||
await userEvent.type(search, 'bob');
|
||||
await waitFor(() => expect(listUrls.some((url) => url.includes('q=bob'))).toBe(true));
|
||||
await userEvent.click(within(dialog).getByTestId('instance-users-banned-only'));
|
||||
await waitFor(() => expect(listUrls.some((url) => url.includes('banned=true'))).toBe(true));
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('настройки: безопасность', () => {
|
||||
|
||||
Reference in New Issue
Block a user