Files
glchat/web/src/pages/settings/GuildModerationSections.tsx
T
grendervill 8613989b21 feat(web): разделы «Баны» и «Журнал аудита» в настройках сервера
Фаза 4 (AGENT.md 7.17, 7.18): модерация из интерфейса.

- `api/moderation.ts`: список банов, бан, разбан, тайм-аут, кик и журнал
  аудита с фильтрами;
- «Баны»: причина и автор каждого бана, снятие бана и пресеты тайм-аута
  (1/5/10/60/1440 минут) прямо из списка; ответ сервера обновляет кэш, поэтому
  строка исчезает без перезагрузки;
- «Журнал аудита»: фильтры по действию, автору и цели уходят в запрос,
  кнопки «Показать» и «Сбросить»;
- секции видны только с правами BAN_MEMBERS и VIEW_AUDIT_LOG
  (`canBanMembers`, `canViewAuditLog`);
- тесты: список с причиной и автором, снятие бана с обновлением списка,
  тайм-аут с проверкой времени, скрытие секций без прав, фильтры в запросе.
2026-09-20 22:43:56 +03:00

300 lines
11 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
fetchGuildAudit,
fetchGuildBans,
guildAuditQueryKey,
guildBansQueryKey,
timeoutGuildMember,
unbanGuildMember,
type AuditFilters,
} from '@/api/moderation';
import { guildMembersQueryKey } from '@/api/guilds';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Badge, Button } from '@/components/ui/primitives';
import { formatDateTime } from '@/lib/identity';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Пресеты тайм-аута: значения в минутах (0 — снять ограничение). */
const TIMEOUT_PRESETS = [1, 5, 10, 60, 1440] as const;
interface GuildBansSectionProps {
guildId: string;
canBan: boolean;
}
/**
* «Баны»: список забаненных с причиной и автором, снятие бана и установка
* тайм-аута участнику (AGENT.md 7.17). Данные обновляются без перезагрузки.
*/
export function GuildBansSection({ guildId, canBan }: GuildBansSectionProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const describeError = useApiErrorMessage();
const queryClient = useQueryClient();
const [timeoutMinutes, setTimeoutMinutes] = useState<number>(10);
const bans = useQuery({
queryKey: guildBansQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildBans(guildId, signal),
enabled: canBan,
retry: 0,
});
const unban = useMutation({
mutationFn: (userId: string) => unbanGuildMember(guildId, userId),
onSuccess: (list) => {
queryClient.setQueryData(guildBansQueryKey(guildId), list);
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
},
});
const timeout = useMutation({
mutationFn: ({ userId, minutes }: { userId: string; minutes: number }) =>
timeoutGuildMember(
guildId,
userId,
minutes === 0
? '2000-01-01T00:00:00Z'
: new Date(Date.now() + minutes * 60_000).toISOString(),
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
},
});
if (!canBan) {
return null;
}
return (
<SettingsSection
title={t('settings.servers.bans.title')}
description={t('settings.servers.bans.description')}
>
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="text-sm text-fg-muted">{t('settings.servers.bans.timeoutLabel')}</span>
{TIMEOUT_PRESETS.map((minutes) => (
<Button
key={minutes}
variant={timeoutMinutes === minutes ? 'primary' : 'ghost'}
onClick={() => setTimeoutMinutes(minutes)}
data-testid={`ban-timeout-preset-${String(minutes)}`}
>
{t('settings.servers.bans.timeoutMinutes', { count: minutes })}
</Button>
))}
</div>
{bans.isPending ? <LoadingNotice /> : null}
{bans.isError ? (
<ErrorNotice className="mt-4" error={bans.error} onRetry={() => void bans.refetch()} />
) : null}
{unban.isError ? (
<p className="mt-3 text-sm text-danger">{describeError(unban.error)}</p>
) : null}
{timeout.isError ? (
<p className="mt-3 text-sm text-danger">{describeError(timeout.error)}</p>
) : null}
{bans.data !== undefined && bans.data.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted" data-testid="guild-bans-empty">
{t('settings.servers.bans.empty')}
</p>
) : null}
{bans.data === undefined || bans.data.length === 0 ? null : (
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-bans">
{bans.data.map((ban) => (
<li
key={ban.user_id}
className="flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
data-testid={`guild-ban-${ban.user_id}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{ban.display_name === undefined || ban.display_name === ''
? ban.username
: ban.display_name}{' '}
<span className="text-fg-muted">@{ban.username}</span>
</span>
<span className="block truncate text-xs text-fg-muted">
{ban.reason === undefined || ban.reason === ''
? t('settings.servers.bans.noReason')
: ban.reason}
{' · '}
{t('settings.servers.bans.bannedBy', {
name:
ban.actor_name === undefined || ban.actor_name === ''
? t('settings.servers.bans.unknownActor')
: ban.actor_name,
date: formatDateTime(ban.created_at, locale),
})}
</span>
</span>
<Badge>{ban.user_id}</Badge>
<Button
variant="ghost"
disabled={unban.isPending || timeout.isPending}
onClick={() => unban.mutate(ban.user_id)}
data-testid={`guild-ban-remove-${ban.user_id}`}
>
{t('settings.servers.bans.remove')}
</Button>
<Button
variant="ghost"
disabled={unban.isPending || timeout.isPending}
onClick={() => timeout.mutate({ userId: ban.user_id, minutes: timeoutMinutes })}
data-testid={`guild-ban-timeout-${ban.user_id}`}
>
{t('settings.servers.bans.timeoutAction', { minutes: timeoutMinutes })}
</Button>
</li>
))}
</ul>
)}
</SettingsSection>
);
}
interface GuildAuditSectionProps {
guildId: string;
canView: boolean;
}
/**
* «Журнал аудита» с фильтрами: действие, автор и цель (AGENT.md 7.18).
* Фильтры уходят в запрос, поэтому сервер отдаёт только нужные записи.
*/
export function GuildAuditSection({ guildId, canView }: GuildAuditSectionProps) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const [filters, setFilters] = useState<AuditFilters>({ limit: 50 });
const [action, setAction] = useState('');
const [actorId, setActorId] = useState('');
const [targetId, setTargetId] = useState('');
const entries = useQuery({
queryKey: guildAuditQueryKey(guildId, filters),
queryFn: ({ signal }) => fetchGuildAudit(guildId, filters, signal),
enabled: canView,
retry: 0,
});
if (!canView) {
return null;
}
const apply = (): void => {
setFilters({
limit: 50,
action: action.trim(),
actorId: actorId.trim(),
targetId: targetId.trim(),
});
};
const reset = (): void => {
setAction('');
setActorId('');
setTargetId('');
setFilters({ limit: 50 });
};
return (
<SettingsSection
title={t('settings.servers.audit.title')}
description={t('settings.servers.audit.description')}
>
<form
className="mt-4 flex flex-wrap items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
apply();
}}
>
<label className="flex flex-col gap-1 text-xs text-fg-muted">
{t('settings.servers.audit.filterAction')}
<input
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder="member.ban"
data-testid="audit-filter-action"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-fg-muted">
{t('settings.servers.audit.filterActor')}
<input
className="w-40 rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={actorId}
onChange={(event) => setActorId(event.target.value)}
inputMode="numeric"
data-testid="audit-filter-actor"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-fg-muted">
{t('settings.servers.audit.filterTarget')}
<input
className="w-40 rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
inputMode="numeric"
data-testid="audit-filter-target"
/>
</label>
<Button type="submit" data-testid="audit-apply">
{t('settings.servers.audit.apply')}
</Button>
<Button variant="ghost" type="button" onClick={reset} data-testid="audit-reset">
{t('settings.servers.audit.reset')}
</Button>
</form>
{entries.isPending ? <LoadingNotice /> : null}
{entries.isError ? (
<ErrorNotice
className="mt-4"
error={entries.error}
onRetry={() => void entries.refetch()}
/>
) : null}
{entries.data !== undefined && entries.data.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted" data-testid="guild-audit-empty">
{t('settings.servers.audit.empty')}
</p>
) : null}
{entries.data === undefined || entries.data.length === 0 ? null : (
<ul className="mt-4 flex flex-col gap-1 text-sm" data-testid="guild-audit">
{entries.data.map((entry) => (
<li
key={entry.id}
className="flex flex-wrap items-center justify-between gap-2 border-b border-border/40 py-2 last:border-b-0"
data-testid={`guild-audit-${entry.id}`}
>
<span className="truncate">
<span className="font-medium">{entry.action}</span>
{entry.actor_name === undefined ? null : (
<span className="text-fg-muted"> · {entry.actor_name}</span>
)}
{entry.target_id === undefined ? null : (
<span className="text-fg-muted"> → {entry.target_id}</span>
)}
{entry.reason === undefined || entry.reason === '' ? null : (
<span className="text-fg-muted"> · {entry.reason}</span>
)}
</span>
<span className="shrink-0 text-xs text-fg-muted">
{formatDateTime(entry.created_at, locale)}
</span>
</li>
))}
</ul>
)}
</SettingsSection>
);
}