feat(web): разделы «Баны» и «Журнал аудита» в настройках сервера
Фаза 4 (AGENT.md 7.17, 7.18): модерация из интерфейса. - `api/moderation.ts`: список банов, бан, разбан, тайм-аут, кик и журнал аудита с фильтрами; - «Баны»: причина и автор каждого бана, снятие бана и пресеты тайм-аута (1/5/10/60/1440 минут) прямо из списка; ответ сервера обновляет кэш, поэтому строка исчезает без перезагрузки; - «Журнал аудита»: фильтры по действию, автору и цели уходят в запрос, кнопки «Показать» и «Сбросить»; - секции видны только с правами BAN_MEMBERS и VIEW_AUDIT_LOG (`canBanMembers`, `canViewAuditLog`); - тесты: список с причиной и автором, снятие бана с обновлением списка, тайм-аут с проверкой времени, скрытие секций без прав, фильтры в запросе.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { request } from './client';
|
||||
|
||||
/** Запись о бане сервера (AGENT.md 7.17). */
|
||||
export interface GuildBan {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
actor_id?: string;
|
||||
actor_name?: string;
|
||||
reason?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Запись журнала аудита сервера (AGENT.md 7.18). */
|
||||
export interface GuildAuditEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
actor_id?: string;
|
||||
actor_name?: string;
|
||||
target_type?: string;
|
||||
target_id?: string;
|
||||
reason?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditFilters {
|
||||
action?: string;
|
||||
actorId?: string;
|
||||
targetId?: string;
|
||||
beforeId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export const guildBansQueryKey = (guildId: string) => ['guilds', guildId, 'bans'] as const;
|
||||
export const guildAuditQueryKey = (guildId: string, filters: AuditFilters) =>
|
||||
['guilds', guildId, 'audit', filters] as const;
|
||||
|
||||
function auditQuery(filters: AuditFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(filters.limit ?? 50));
|
||||
if (filters.action !== undefined && filters.action !== '') {
|
||||
params.set('action', filters.action);
|
||||
}
|
||||
if (filters.actorId !== undefined && filters.actorId !== '') {
|
||||
params.set('actor_id', filters.actorId);
|
||||
}
|
||||
if (filters.targetId !== undefined && filters.targetId !== '') {
|
||||
params.set('target_id', filters.targetId);
|
||||
}
|
||||
if (filters.beforeId !== undefined && filters.beforeId !== '') {
|
||||
params.set('before_id', filters.beforeId);
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function fetchGuildBans(guildId: string, signal?: AbortSignal): Promise<GuildBan[]> {
|
||||
const payload = await request<{ bans: GuildBan[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/bans`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.bans;
|
||||
}
|
||||
|
||||
/** Бан участника: сервер исключает его и запрещает повторный вход. */
|
||||
export async function banGuildMember(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
reason: string,
|
||||
): Promise<GuildBan[]> {
|
||||
const payload = await request<{ bans: GuildBan[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/bans/${encodeURIComponent(userId)}`,
|
||||
{ method: 'PUT', body: { reason } },
|
||||
);
|
||||
return payload.bans;
|
||||
}
|
||||
|
||||
export async function unbanGuildMember(guildId: string, userId: string): Promise<GuildBan[]> {
|
||||
const payload = await request<{ bans: GuildBan[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/bans/${encodeURIComponent(userId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
return payload.bans;
|
||||
}
|
||||
|
||||
/** Тайм-аут участника: пустая строка снимает ограничение. */
|
||||
export async function timeoutGuildMember(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
timeoutUntil: string,
|
||||
): Promise<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`,
|
||||
{ method: 'PATCH', body: { timeout_until: timeoutUntil } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function kickGuildMember(guildId: string, userId: string): Promise<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchGuildAudit(
|
||||
guildId: string,
|
||||
filters: AuditFilters,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GuildAuditEntry[]> {
|
||||
const payload = await request<{ entries: GuildAuditEntry[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/audit-log?${auditQuery(filters)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.entries;
|
||||
}
|
||||
@@ -756,7 +756,7 @@
|
||||
"ping": "Ping",
|
||||
"pingValue": "{{ms}} ms",
|
||||
"pingUnavailable": "—",
|
||||
"pingHint": "Instance response time for a dashboard request",
|
||||
"pingHint": "Instance response time for a dashboard request (HTTP)",
|
||||
"summary": {
|
||||
"ok": "All good",
|
||||
"warn": "Warnings",
|
||||
@@ -918,6 +918,31 @@
|
||||
"role_grant": "Role granted",
|
||||
"role_revoke": "Role revoked"
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
"title": "Bans",
|
||||
"description": "Banned users cannot return by invite until the ban is lifted.",
|
||||
"empty": "No bans.",
|
||||
"remove": "Unban",
|
||||
"noReason": "no reason",
|
||||
"bannedBy": "banned by {{name}}, {{date}}",
|
||||
"unknownActor": "unknown",
|
||||
"timeoutLabel": "Timeout:",
|
||||
"timeoutMinutes_one": "{{count}} minute",
|
||||
"timeoutMinutes_few": "{{count}} minutes",
|
||||
"timeoutMinutes_many": "{{count}} minutes",
|
||||
"timeoutMinutes_other": "{{count}} minutes",
|
||||
"timeoutAction": "Timeout {{minutes}} min"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit log",
|
||||
"description": "Guild moderator actions. Filters are applied server-side.",
|
||||
"filterAction": "Action",
|
||||
"filterActor": "Actor ID",
|
||||
"filterTarget": "Target ID",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"empty": "No entries."
|
||||
}
|
||||
},
|
||||
"navLabel": "Settings sections",
|
||||
|
||||
@@ -756,7 +756,7 @@
|
||||
"ping": "Пинг",
|
||||
"pingValue": "{{ms}} мс",
|
||||
"pingUnavailable": "—",
|
||||
"pingHint": "Время ответа инстанса на запрос дашборда",
|
||||
"pingHint": "Время ответа инстанса на запрос дашборда (HTTP)",
|
||||
"summary": {
|
||||
"ok": "Всё в порядке",
|
||||
"warn": "Есть предупреждения",
|
||||
@@ -918,6 +918,31 @@
|
||||
"role_grant": "Выдача роли",
|
||||
"role_revoke": "Снятие роли"
|
||||
}
|
||||
},
|
||||
"bans": {
|
||||
"title": "Баны",
|
||||
"description": "Забаненные не могут вернуться по приглашению, пока бан не снят.",
|
||||
"empty": "Забаненных нет.",
|
||||
"remove": "Снять бан",
|
||||
"noReason": "без причины",
|
||||
"bannedBy": "забанил {{name}}, {{date}}",
|
||||
"unknownActor": "неизвестный",
|
||||
"timeoutLabel": "Тайм-аут:",
|
||||
"timeoutMinutes_one": "{{count}} минута",
|
||||
"timeoutMinutes_few": "{{count}} минуты",
|
||||
"timeoutMinutes_many": "{{count}} минут",
|
||||
"timeoutMinutes_other": "{{count}} минуты",
|
||||
"timeoutAction": "Тайм-аут {{minutes}} мин"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Журнал аудита",
|
||||
"description": "Действия модераторов сервера. Фильтры уходят в запрос.",
|
||||
"filterAction": "Действие",
|
||||
"filterActor": "ID автора",
|
||||
"filterTarget": "ID цели",
|
||||
"apply": "Показать",
|
||||
"reset": "Сбросить",
|
||||
"empty": "Записей нет."
|
||||
}
|
||||
},
|
||||
"navLabel": "Пункты настроек",
|
||||
|
||||
@@ -42,6 +42,8 @@ export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
|
||||
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
|
||||
export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
|
||||
export const PERMISSION_MANAGE_SOUNDS = 'MANAGE_SOUNDS';
|
||||
export const PERMISSION_BAN_MEMBERS = 'BAN_MEMBERS';
|
||||
export const PERMISSION_VIEW_AUDIT_LOG = 'VIEW_AUDIT_LOG';
|
||||
export const PERMISSION_USE_SOUNDBOARD = 'USE_SOUNDBOARD';
|
||||
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
|
||||
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
|
||||
@@ -188,6 +190,44 @@ export function canManageEmojis(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь банить участников (AGENT.md 7.17): владелец,
|
||||
* ADMINISTRATOR, BAN_MEMBERS или администратор инстанса.
|
||||
*/
|
||||
export function canBanMembers(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
isInstanceAdmin = false,
|
||||
): boolean {
|
||||
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_BAN_MEMBERS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь смотреть журнал аудита (AGENT.md 7.18): владелец,
|
||||
* ADMINISTRATOR, VIEW_AUDIT_LOG или администратор инстанса.
|
||||
*/
|
||||
export function canViewAuditLog(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
isInstanceAdmin = false,
|
||||
): boolean {
|
||||
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_VIEW_AUDIT_LOG)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь управлять звуками сервера (AGENT.md 7.13): владелец,
|
||||
* ADMINISTRATOR, MANAGE_SOUNDS или администратор инстанса.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,14 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canAdministerGuild, canManageEmojis, canManageSounds } from '@/lib/identity';
|
||||
import { GuildAuditSection, GuildBansSection } from '@/pages/settings/GuildModerationSections';
|
||||
import {
|
||||
canAdministerGuild,
|
||||
canBanMembers,
|
||||
canManageEmojis,
|
||||
canManageSounds,
|
||||
canViewAuditLog,
|
||||
} from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
@@ -151,6 +158,20 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
// «Баны» и журнал аудита (AGENT.md 7.17, 7.18).
|
||||
const canBan = canBanMembers(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
currentUser.data?.id,
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
const canViewAudit = canViewAuditLog(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
currentUser.data?.id,
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
|
||||
const canManageSoundSet = canManageSounds(
|
||||
guild.my_permissions,
|
||||
@@ -175,6 +196,8 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
{canManageSoundSet ? (
|
||||
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
|
||||
) : null}
|
||||
<GuildBansSection key={`${guild.id}-bans`} guildId={guild.id} canBan={canBan} />
|
||||
<GuildAuditSection key={`${guild.id}-audit`} guildId={guild.id} canView={canViewAudit} />
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
bodyOf,
|
||||
installFetch,
|
||||
json,
|
||||
makeUser,
|
||||
recordedRequests,
|
||||
renderApp,
|
||||
resetStores,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Модерация в настройках сервера (AGENT.md 7.17, 7.18): список банов со
|
||||
* снятием и тайм-аутом, журнал аудита с фильтрами. Фильтры уходят в запрос,
|
||||
* а не фильтруются на клиенте.
|
||||
*/
|
||||
|
||||
const user = makeUser();
|
||||
|
||||
const bans = [
|
||||
{
|
||||
user_id: 'u-7',
|
||||
username: 'spammer',
|
||||
display_name: 'Spammer',
|
||||
actor_id: 'user-1',
|
||||
actor_name: 'Alice',
|
||||
reason: 'реклама',
|
||||
created_at: '2026-09-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
user_id: 'u-8',
|
||||
username: 'noisy',
|
||||
display_name: '',
|
||||
actor_id: 'user-1',
|
||||
actor_name: 'Alice',
|
||||
reason: '',
|
||||
created_at: '2026-09-20T11:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const audit = [
|
||||
{
|
||||
id: 'a-2',
|
||||
action: 'member.ban',
|
||||
actor_id: 'user-1',
|
||||
actor_name: 'Alice',
|
||||
target_type: 'user',
|
||||
target_id: 'u-7',
|
||||
reason: 'реклама',
|
||||
created_at: '2026-09-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'a-1',
|
||||
action: 'channel.create',
|
||||
actor_id: 'user-1',
|
||||
actor_name: 'Alice',
|
||||
target_type: 'channel',
|
||||
target_id: 'c-9',
|
||||
created_at: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
function guildDetail(permissions: string[]) {
|
||||
return {
|
||||
id: 'g-1',
|
||||
name: 'Сервер',
|
||||
description: '',
|
||||
owner_id: 'user-9',
|
||||
is_main: true,
|
||||
member_count: 2,
|
||||
roles: [],
|
||||
my_role_ids: [],
|
||||
my_permissions: permissions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Маршруты оболочки, карточки сервера, банов и журнала аудита. */
|
||||
function routes(permissions: string[] = ['BAN_MEMBERS', 'VIEW_AUDIT_LOG']): FetchRoute[] {
|
||||
// Мок помнит состояние: снятие бана возвращает список без этой записи.
|
||||
let current = bans;
|
||||
return [
|
||||
{ match: '/api/v1/guilds/g-1/bans', response: () => json({ bans: current }) },
|
||||
{
|
||||
match: '/api/v1/guilds/g-1/bans',
|
||||
method: 'DELETE',
|
||||
response: (request) => {
|
||||
const userId = request.url.split('/').at(-1);
|
||||
current = current.filter((ban) => ban.user_id !== userId);
|
||||
return json({ bans: current });
|
||||
},
|
||||
},
|
||||
{
|
||||
match: '/api/v1/guilds/g-1/audit-log',
|
||||
response: () => json({ entries: audit }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/users/@me/guilds',
|
||||
response: () => json({ guilds: [guildDetail(permissions)] }),
|
||||
},
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) },
|
||||
];
|
||||
}
|
||||
|
||||
async function openServerSettings(permissions?: string[]) {
|
||||
const fetchMock = installFetch(routes(permissions));
|
||||
renderApp('/settings/servers/g-1');
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||
return { dialog, fetchMock };
|
||||
}
|
||||
|
||||
/** Последний запрос по URL и методу. */
|
||||
function lastRequest(fetchMock: ReturnType<typeof installFetch>, url: string, method: string) {
|
||||
return recordedRequests(fetchMock)
|
||||
.filter((request) => request.url.includes(url) && request.method.toUpperCase() === method)
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
describe('модерация: баны', () => {
|
||||
it('показывает список с причиной и автором', async () => {
|
||||
const { dialog } = await openServerSettings();
|
||||
|
||||
const list = await within(dialog).findByTestId('guild-bans');
|
||||
expect(list).toHaveTextContent('Spammer');
|
||||
expect(within(list).getByTestId('guild-ban-u-7')).toHaveTextContent('реклама');
|
||||
expect(within(list).getByTestId('guild-ban-u-7')).toHaveTextContent('Alice');
|
||||
// Пустая причина показывается как «без причины».
|
||||
expect(within(list).getByTestId('guild-ban-u-8')).toHaveTextContent('без причины');
|
||||
});
|
||||
|
||||
it('снимает бан запросом и обновляет список', async () => {
|
||||
const { dialog, fetchMock } = await openServerSettings();
|
||||
const list = await within(dialog).findByTestId('guild-bans');
|
||||
|
||||
await userEvent.click(within(list).getByTestId('guild-ban-remove-u-7'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(fetchMock, '/guilds/g-1/bans/u-7', 'DELETE')).toBeDefined();
|
||||
});
|
||||
// Ответ PUT/DELETE — новый список, поэтому строка исчезает без перезапроса.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('guild-ban-u-7')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('ставит тайм-аут с выбранным пресетом', async () => {
|
||||
const { dialog, fetchMock } = await openServerSettings();
|
||||
const list = await within(dialog).findByTestId('guild-bans');
|
||||
|
||||
await userEvent.click(screen.getByTestId('ban-timeout-preset-60'));
|
||||
await userEvent.click(within(list).getByTestId('guild-ban-timeout-u-8'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastRequest(fetchMock, '/guilds/g-1/members/u-8', 'PATCH')).toBeDefined();
|
||||
});
|
||||
const request = lastRequest(fetchMock, '/guilds/g-1/members/u-8', 'PATCH');
|
||||
const body = bodyOf(request) as { timeout_until?: string } | undefined;
|
||||
const until = new Date(body?.timeout_until ?? '');
|
||||
// Пресет «60» — примерно час вперёд (проверяем с запасом в минуту).
|
||||
const minutes = (until.getTime() - Date.now()) / 60_000;
|
||||
expect(minutes).toBeGreaterThan(58);
|
||||
expect(minutes).toBeLessThan(61);
|
||||
});
|
||||
|
||||
it('скрывает секцию без права BAN_MEMBERS', async () => {
|
||||
const { dialog } = await openServerSettings(['VIEW_AUDIT_LOG']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).queryByTestId('guild-bans')).toBeNull();
|
||||
expect(within(dialog).queryByTestId('guild-bans-empty')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('модерация: журнал аудита', () => {
|
||||
it('показывает записи и отправляет фильтры в запрос', async () => {
|
||||
const { dialog, fetchMock } = await openServerSettings();
|
||||
|
||||
const list = await within(dialog).findByTestId('guild-audit');
|
||||
expect(list).toHaveTextContent('member.ban');
|
||||
expect(within(list).getByTestId('guild-audit-a-2')).toHaveTextContent('Alice');
|
||||
expect(within(list).getByTestId('guild-audit-a-2')).toHaveTextContent('u-7');
|
||||
|
||||
await userEvent.type(screen.getByTestId('audit-filter-action'), 'member.ban');
|
||||
await userEvent.type(screen.getByTestId('audit-filter-target'), 'u-7');
|
||||
await userEvent.click(screen.getByTestId('audit-apply'));
|
||||
|
||||
await waitFor(() => {
|
||||
const url =
|
||||
recordedRequests(fetchMock)
|
||||
.filter((item) => item.url.includes('/audit-log'))
|
||||
.at(-1)?.url ?? '';
|
||||
expect(url, `URL запроса: ${url}`).toContain('action=member.ban');
|
||||
expect(url).toContain('target_id=u-7');
|
||||
});
|
||||
});
|
||||
|
||||
it('сброс фильтров возвращает запрос без параметров', async () => {
|
||||
const { dialog, fetchMock } = await openServerSettings();
|
||||
await within(dialog).findByTestId('guild-audit');
|
||||
|
||||
await userEvent.type(screen.getByTestId('audit-filter-action'), 'member.ban');
|
||||
await userEvent.click(screen.getByTestId('audit-apply'));
|
||||
await userEvent.click(screen.getByTestId('audit-reset'));
|
||||
|
||||
await waitFor(() => {
|
||||
const url =
|
||||
recordedRequests(fetchMock)
|
||||
.filter((item) => item.url.includes('/audit-log'))
|
||||
.at(-1)?.url ?? '';
|
||||
expect(url).not.toContain('action=');
|
||||
});
|
||||
expect(screen.getByTestId('audit-filter-action')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('скрывает журнал без права VIEW_AUDIT_LOG', async () => {
|
||||
const { dialog } = await openServerSettings(['BAN_MEMBERS']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).queryByTestId('guild-audit')).toBeNull();
|
||||
expect(within(dialog).queryByTestId('guild-audit-empty')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user