From f1a195ec9df88f223bab24ff4f356c7bb15d4a37 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 23:04:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=83=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Банить и исключать нужно того, кто прямо сейчас в сервере, а не только разбирать готовый список банов (AGENT.md 7.17). - `MemberModerationMenu`: тайм-аут (1/10/60/1440 минут и снятие), бан с причиной и исключение; пункты скрыты без прав KICK_MEMBERS/BAN_MEMBERS/TIMEOUT_MEMBERS, себя модерировать нельзя; - после действия обновляются списки участников и банов (без F5); - тесты: бан с причиной, тайм-аут, исключение и скрытие меню без прав. --- web/src/i18n/locales/en.json | 9 +- web/src/i18n/locales/ru.json | 9 +- .../pages/settings/GuildMembersSection.tsx | 23 +++ web/src/pages/settings/GuildSettingsPage.tsx | 12 ++ .../pages/settings/MemberModerationMenu.tsx | 171 ++++++++++++++++++ web/tests/moderationSettings.test.tsx | 75 +++++++- 6 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 web/src/pages/settings/MemberModerationMenu.tsx diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 94e1268..9dce42d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -815,7 +815,14 @@ "description": "The first {{limit}} members of the server.", "empty": "No members found.", "roles": "Roles: {{roles}}", - "more": "The full member list with roles arrives in the next phases." + "more": "The full member list with roles arrives in the next phases.", + "moderate": "Moderate {{name}}", + "timeout": "Timeout", + "timeoutClear": "Clear timeout", + "banReason": "Ban reason", + "banReasonPlaceholder": "optional", + "ban": "Ban", + "kick": "Kick" }, "roles": { "title": "Roles", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f8c1f29..f51fac9 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -815,7 +815,14 @@ "description": "Первые {{limit}} участников сервера.", "empty": "Участники не найдены.", "roles": "Ролей: {{roles}}", - "more": "Полный список участников с ролями появится в следующих фазах." + "more": "Полный список участников с ролями появится в следующих фазах.", + "moderate": "Модерировать {{name}}", + "timeout": "Тайм-аут", + "timeoutClear": "Снять тайм-аут", + "banReason": "Причина бана", + "banReasonPlaceholder": "необязательно", + "ban": "Забанить", + "kick": "Исключить" }, "roles": { "title": "Роли", diff --git a/web/src/pages/settings/GuildMembersSection.tsx b/web/src/pages/settings/GuildMembersSection.tsx index 0bde782..2953c4a 100644 --- a/web/src/pages/settings/GuildMembersSection.tsx +++ b/web/src/pages/settings/GuildMembersSection.tsx @@ -5,13 +5,21 @@ import { Avatar } from '@/components/ui/Avatar'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Badge } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; +import { MemberModerationMenu } from '@/pages/settings/MemberModerationMenu'; import { SettingsSection } from '@/pages/settings/SettingsSection'; interface GuildMembersSectionProps { + guildId: string; members: GuildMember[] | undefined; isPending: boolean; error: unknown; onRetry: () => void; + /** Права действующего пользователя: без них меню модерации не показываем. */ + canKick?: boolean; + canBan?: boolean; + canTimeout?: boolean; + /** Свой профиль: себя модерировать нельзя. */ + currentUserId?: string | undefined; } /** Сколько участников показываем в превью (полный список — в следующих фазах). */ @@ -19,10 +27,15 @@ const PREVIEW_LIMIT = 10; /** «Участники»: превью первых десяти с аватаром, именем и числом ролей. */ export function GuildMembersSection({ + guildId, members, isPending, error, onRetry, + canKick = false, + canBan = false, + canTimeout = false, + currentUserId, }: GuildMembersSectionProps) { const { t } = useTranslation(); const preview = (members ?? []).slice(0, PREVIEW_LIMIT); @@ -67,6 +80,16 @@ export function GuildMembersSection({ {t('settings.servers.members.roles', { roles: member.role_ids.length })} + {member.user_id === currentUserId ? null : ( + + )} ); })} diff --git a/web/src/pages/settings/GuildSettingsPage.tsx b/web/src/pages/settings/GuildSettingsPage.tsx index e8907bc..298e2de 100644 --- a/web/src/pages/settings/GuildSettingsPage.tsx +++ b/web/src/pages/settings/GuildSettingsPage.tsx @@ -158,6 +158,13 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) { currentUser.data?.is_instance_admin === true, ); + // Исключение и тайм-аут: владелец, ADMINISTRATOR, право или админ инстанса. + const canModerateMembers = (permission: string): boolean => + currentUser.data?.is_instance_admin === true || + guild.owner_id === currentUser.data?.id || + guild.my_permissions.includes('ADMINISTRATOR') || + guild.my_permissions.includes(permission); + // «Баны» и журнал аудита (AGENT.md 7.17, 7.18). const canBan = canBanMembers( guild.my_permissions, @@ -184,10 +191,15 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) { <> void members.refetch()} + canKick={canModerateMembers('KICK_MEMBERS')} + canBan={canBan} + canTimeout={canModerateMembers('TIMEOUT_MEMBERS')} + currentUserId={currentUser.data?.id} /> {canManageEmojiSet ? ( diff --git a/web/src/pages/settings/MemberModerationMenu.tsx b/web/src/pages/settings/MemberModerationMenu.tsx new file mode 100644 index 0000000..9dc20ea --- /dev/null +++ b/web/src/pages/settings/MemberModerationMenu.tsx @@ -0,0 +1,171 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { guildMembersQueryKey } from '@/api/guilds'; +import { + banGuildMember, + guildBansQueryKey, + kickGuildMember, + timeoutGuildMember, +} from '@/api/moderation'; +import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; + +/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */ +const TIMEOUT_CHOICES = [1, 10, 60, 1440, 0] as const; + +interface MemberModerationMenuProps { + guildId: string; + userId: string; + userName: string; + canKick: boolean; + canBan: boolean; + canTimeout: boolean; +} + +/** + * Меню модерации участника в списке сервера (AGENT.md 7.17): тайм-аут, + * исключение и бан с причиной. Пункты скрыты без соответствующих прав, а + * сервер всё равно проверяет их повторно. + */ +export function MemberModerationMenu({ + guildId, + userId, + userName, + canKick, + canBan, + canTimeout, +}: MemberModerationMenuProps) { + const { t } = useTranslation(); + const describeError = useApiErrorMessage(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(''); + const anchorRef = useRef(null); + const close = useCallback(() => setOpen(false), []); + + const refresh = (): void => { + void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) }); + void queryClient.invalidateQueries({ queryKey: guildBansQueryKey(guildId) }); + }; + + const timeout = useMutation({ + mutationFn: (minutes: number) => + timeoutGuildMember( + guildId, + userId, + minutes === 0 + ? '2000-01-01T00:00:00Z' + : new Date(Date.now() + minutes * 60_000).toISOString(), + ), + onSuccess: () => { + refresh(); + close(); + }, + }); + const kick = useMutation({ + mutationFn: () => kickGuildMember(guildId, userId), + onSuccess: () => { + refresh(); + close(); + }, + }); + const ban = useMutation({ + mutationFn: () => banGuildMember(guildId, userId, reason.trim()), + onSuccess: () => { + refresh(); + close(); + }, + }); + + if (!canKick && !canBan && !canTimeout) { + return null; + } + const error = timeout.error ?? kick.error ?? ban.error; + const pending = timeout.isPending || kick.isPending || ban.isPending; + + return ( + <> + + + + {canTimeout ? ( + <> + {t('settings.servers.members.timeout')} + {TIMEOUT_CHOICES.map((minutes) => ( + timeout.mutate(minutes)} + > + {minutes === 0 + ? t('settings.servers.members.timeoutClear') + : t('settings.servers.bans.timeoutMinutes', { count: minutes })} + + ))} + + + ) : null} + + {canBan ? ( + <> + {t('settings.servers.members.banReason')} + + ban.mutate()} + > + {t('settings.servers.members.ban')} + + + ) : null} + + {canKick ? ( + kick.mutate()} + > + {t('settings.servers.members.kick')} + + ) : null} + + {error === null || error === undefined ? null : ( +

{describeError(error)}

+ )} +
+ + ); +} diff --git a/web/tests/moderationSettings.test.tsx b/web/tests/moderationSettings.test.tsx index af9f0b0..0720277 100644 --- a/web/tests/moderationSettings.test.tsx +++ b/web/tests/moderationSettings.test.tsx @@ -103,7 +103,21 @@ function routes(permissions: string[] = ['BAN_MEMBERS', 'VIEW_AUDIT_LOG']): Fetc }, { 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/members', + response: () => + json({ + members: [ + { + user_id: 'u-7', + username: 'spammer', + display_name: 'Spammer', + status: 'offline', + role_ids: [], + }, + ], + }), + }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) }, ]; @@ -183,6 +197,65 @@ describe('модерация: баны', () => { }); }); +describe('модерация: меню участника', () => { + it('банит участника с причиной из меню', async () => { + const { dialog, fetchMock } = await openServerSettings(); + + await userEvent.click(await within(dialog).findByTestId('member-moderate-u-7')); + await userEvent.type(screen.getByTestId('member-ban-reason-u-7'), 'реклама'); + await userEvent.click(await screen.findByTestId('member-ban-u-7')); + + await waitFor(() => { + expect(lastRequest(fetchMock, '/guilds/g-1/bans/u-7', 'PUT')).toBeDefined(); + }); + const body = bodyOf(lastRequest(fetchMock, '/guilds/g-1/bans/u-7', 'PUT')) as { + reason?: string; + }; + expect(body.reason).toBe('реклама'); + }); + + it('ставит тайм-аут участнику из меню', async () => { + const { dialog, fetchMock } = await openServerSettings([ + 'BAN_MEMBERS', + 'KICK_MEMBERS', + 'TIMEOUT_MEMBERS', + 'VIEW_AUDIT_LOG', + ]); + await userEvent.click(await within(dialog).findByTestId('member-moderate-u-7')); + + await userEvent.click(await screen.findByTestId('member-timeout-60')); + await waitFor(() => { + expect(lastRequest(fetchMock, '/guilds/g-1/members/u-7', 'PATCH')).toBeDefined(); + }); + const body = bodyOf(lastRequest(fetchMock, '/guilds/g-1/members/u-7', 'PATCH')) as { + timeout_until?: string; + }; + expect(new Date(body.timeout_until ?? '').getTime()).toBeGreaterThan(Date.now()); + }); + + it('исключает участника из меню', async () => { + const { dialog, fetchMock } = await openServerSettings([ + 'BAN_MEMBERS', + 'KICK_MEMBERS', + 'TIMEOUT_MEMBERS', + 'VIEW_AUDIT_LOG', + ]); + await userEvent.click(await within(dialog).findByTestId('member-moderate-u-7')); + + await userEvent.click(await screen.findByTestId('member-kick-u-7')); + await waitFor(() => { + expect(lastRequest(fetchMock, '/guilds/g-1/members/u-7', 'DELETE')).toBeDefined(); + }); + }); + + it('скрывает меню без прав модерации', async () => { + const { dialog } = await openServerSettings(['VIEW_AUDIT_LOG']); + + await within(dialog).findByTestId('guild-members'); + expect(within(dialog).queryByTestId('member-moderate-u-7')).toBeNull(); + }); +}); + describe('модерация: журнал аудита', () => { it('показывает записи и отправляет фильтры в запрос', async () => { const { dialog, fetchMock } = await openServerSettings();