diff --git a/web/src/api/moderation.ts b/web/src/api/moderation.ts new file mode 100644 index 0000000..8729e77 --- /dev/null +++ b/web/src/api/moderation.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await request<{ ok: true }>( + `/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`, + { method: 'DELETE' }, + ); +} + +export async function fetchGuildAudit( + guildId: string, + filters: AuditFilters, + signal?: AbortSignal, +): Promise { + const payload = await request<{ entries: GuildAuditEntry[] }>( + `/guilds/${encodeURIComponent(guildId)}/audit-log?${auditQuery(filters)}`, + signal === undefined ? {} : { signal }, + ); + return payload.entries; +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b643bd2..94e1268 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 204c896..f8c1f29 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Пункты настроек", diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts index efed8b7..bc119f8 100644 --- a/web/src/lib/identity.ts +++ b/web/src/lib/identity.ts @@ -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 или администратор инстанса. diff --git a/web/src/pages/settings/GuildModerationSections.tsx b/web/src/pages/settings/GuildModerationSections.tsx new file mode 100644 index 0000000..c5b28b7 --- /dev/null +++ b/web/src/pages/settings/GuildModerationSections.tsx @@ -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(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 ( + +
+ {t('settings.servers.bans.timeoutLabel')} + {TIMEOUT_PRESETS.map((minutes) => ( + + ))} +
+ + {bans.isPending ? : null} + {bans.isError ? ( + void bans.refetch()} /> + ) : null} + {unban.isError ? ( +

{describeError(unban.error)}

+ ) : null} + {timeout.isError ? ( +

{describeError(timeout.error)}

+ ) : null} + {bans.data !== undefined && bans.data.length === 0 ? ( +

+ {t('settings.servers.bans.empty')} +

+ ) : null} + + {bans.data === undefined || bans.data.length === 0 ? null : ( +
    + {bans.data.map((ban) => ( +
  • + + + {ban.display_name === undefined || ban.display_name === '' + ? ban.username + : ban.display_name}{' '} + @{ban.username} + + + {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), + })} + + + {ban.user_id} + + +
  • + ))} +
+ )} +
+ ); +} + +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({ 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 ( + +
{ + event.preventDefault(); + apply(); + }} + > + + + + + +
+ + {entries.isPending ? : null} + {entries.isError ? ( + void entries.refetch()} + /> + ) : null} + {entries.data !== undefined && entries.data.length === 0 ? ( +

+ {t('settings.servers.audit.empty')} +

+ ) : null} + + {entries.data === undefined || entries.data.length === 0 ? null : ( +
    + {entries.data.map((entry) => ( +
  • + + {entry.action} + {entry.actor_name === undefined ? null : ( + · {entry.actor_name} + )} + {entry.target_id === undefined ? null : ( + → {entry.target_id} + )} + {entry.reason === undefined || entry.reason === '' ? null : ( + · {entry.reason} + )} + + + {formatDateTime(entry.created_at, locale)} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/src/pages/settings/GuildSettingsPage.tsx b/web/src/pages/settings/GuildSettingsPage.tsx index 0e0ac80..e8907bc 100644 --- a/web/src/pages/settings/GuildSettingsPage.tsx +++ b/web/src/pages/settings/GuildSettingsPage.tsx @@ -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 ? ( ) : null} + + 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, 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(); + }); + }); +});