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 (
+ <>
+
+
+
+ >
+ );
+}
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();