8613989b21
Фаза 4 (AGENT.md 7.17, 7.18): модерация из интерфейса. - `api/moderation.ts`: список банов, бан, разбан, тайм-аут, кик и журнал аудита с фильтрами; - «Баны»: причина и автор каждого бана, снятие бана и пресеты тайм-аута (1/5/10/60/1440 минут) прямо из списка; ответ сервера обновляет кэш, поэтому строка исчезает без перезагрузки; - «Журнал аудита»: фильтры по действию, автору и цели уходят в запрос, кнопки «Показать» и «Сбросить»; - секции видны только с правами BAN_MEMBERS и VIEW_AUDIT_LOG (`canBanMembers`, `canViewAuditLog`); - тесты: список с причиной и автором, снятие бана с обновлением списка, тайм-аут с проверкой времени, скрытие секций без прав, фильтры в запросе.
300 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|