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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user