feat(web): меню модерации участника в списке сервера

Банить и исключать нужно того, кто прямо сейчас в сервере, а не только
разбирать готовый список банов (AGENT.md 7.17).

- `MemberModerationMenu`: тайм-аут (1/10/60/1440 минут и снятие), бан с причиной
  и исключение; пункты скрыты без прав KICK_MEMBERS/BAN_MEMBERS/TIMEOUT_MEMBERS,
  себя модерировать нельзя;
- после действия обновляются списки участников и банов (без F5);
- тесты: бан с причиной, тайм-аут, исключение и скрытие меню без прав.
This commit is contained in:
2026-09-20 23:04:35 +03:00
parent dff4a25332
commit f1a195ec9d
6 changed files with 296 additions and 3 deletions
+8 -1
View File
@@ -815,7 +815,14 @@
"description": "The first {{limit}} members of the server.", "description": "The first {{limit}} members of the server.",
"empty": "No members found.", "empty": "No members found.",
"roles": "Roles: {{roles}}", "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": { "roles": {
"title": "Roles", "title": "Roles",
+8 -1
View File
@@ -815,7 +815,14 @@
"description": "Первые {{limit}} участников сервера.", "description": "Первые {{limit}} участников сервера.",
"empty": "Участники не найдены.", "empty": "Участники не найдены.",
"roles": "Ролей: {{roles}}", "roles": "Ролей: {{roles}}",
"more": "Полный список участников с ролями появится в следующих фазах." "more": "Полный список участников с ролями появится в следующих фазах.",
"moderate": "Модерировать {{name}}",
"timeout": "Тайм-аут",
"timeoutClear": "Снять тайм-аут",
"banReason": "Причина бана",
"banReasonPlaceholder": "необязательно",
"ban": "Забанить",
"kick": "Исключить"
}, },
"roles": { "roles": {
"title": "Роли", "title": "Роли",
@@ -5,13 +5,21 @@ import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Badge } from '@/components/ui/primitives'; import { Badge } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton'; import { SkeletonLines } from '@/components/ui/Skeleton';
import { MemberModerationMenu } from '@/pages/settings/MemberModerationMenu';
import { SettingsSection } from '@/pages/settings/SettingsSection'; import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildMembersSectionProps { interface GuildMembersSectionProps {
guildId: string;
members: GuildMember[] | undefined; members: GuildMember[] | undefined;
isPending: boolean; isPending: boolean;
error: unknown; error: unknown;
onRetry: () => void; onRetry: () => void;
/** Права действующего пользователя: без них меню модерации не показываем. */
canKick?: boolean;
canBan?: boolean;
canTimeout?: boolean;
/** Свой профиль: себя модерировать нельзя. */
currentUserId?: string | undefined;
} }
/** Сколько участников показываем в превью (полный список — в следующих фазах). */ /** Сколько участников показываем в превью (полный список — в следующих фазах). */
@@ -19,10 +27,15 @@ const PREVIEW_LIMIT = 10;
/** «Участники»: превью первых десяти с аватаром, именем и числом ролей. */ /** «Участники»: превью первых десяти с аватаром, именем и числом ролей. */
export function GuildMembersSection({ export function GuildMembersSection({
guildId,
members, members,
isPending, isPending,
error, error,
onRetry, onRetry,
canKick = false,
canBan = false,
canTimeout = false,
currentUserId,
}: GuildMembersSectionProps) { }: GuildMembersSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const preview = (members ?? []).slice(0, PREVIEW_LIMIT); const preview = (members ?? []).slice(0, PREVIEW_LIMIT);
@@ -67,6 +80,16 @@ export function GuildMembersSection({
<Badge> <Badge>
{t('settings.servers.members.roles', { roles: member.role_ids.length })} {t('settings.servers.members.roles', { roles: member.role_ids.length })}
</Badge> </Badge>
{member.user_id === currentUserId ? null : (
<MemberModerationMenu
guildId={guildId}
userId={member.user_id}
userName={name}
canKick={canKick}
canBan={canBan}
canTimeout={canTimeout}
/>
)}
</li> </li>
); );
})} })}
@@ -158,6 +158,13 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
currentUser.data?.is_instance_admin === true, 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). // «Баны» и журнал аудита (AGENT.md 7.17, 7.18).
const canBan = canBanMembers( const canBan = canBanMembers(
guild.my_permissions, guild.my_permissions,
@@ -184,10 +191,15 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
<> <>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} /> <GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildMembersSection <GuildMembersSection
guildId={guild.id}
members={members.data} members={members.data}
isPending={members.isPending} isPending={members.isPending}
error={members.error} error={members.error}
onRetry={() => void members.refetch()} onRetry={() => void members.refetch()}
canKick={canModerateMembers('KICK_MEMBERS')}
canBan={canBan}
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
currentUserId={currentUser.data?.id}
/> />
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} /> <GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
{canManageEmojiSet ? ( {canManageEmojiSet ? (
@@ -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<HTMLButtonElement | null>(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 (
<>
<button
ref={anchorRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('settings.servers.members.moderate', { name: userName })}
title={t('settings.servers.members.moderate', { name: userName })}
data-testid={`member-moderate-${userId}`}
onClick={() => setOpen((value) => !value)}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
>
<span aria-hidden="true">⋯</span>
</button>
<Menu
open={open}
onClose={close}
anchorRef={anchorRef}
label={t('settings.servers.members.moderate', { name: userName })}
placement="bottom"
>
{canTimeout ? (
<>
<MenuGroupLabel>{t('settings.servers.members.timeout')}</MenuGroupLabel>
{TIMEOUT_CHOICES.map((minutes) => (
<MenuItem
key={minutes}
disabled={pending}
data-testid={`member-timeout-${minutes}`}
onClick={() => timeout.mutate(minutes)}
>
{minutes === 0
? t('settings.servers.members.timeoutClear')
: t('settings.servers.bans.timeoutMinutes', { count: minutes })}
</MenuItem>
))}
<MenuSeparator />
</>
) : null}
{canBan ? (
<>
<MenuGroupLabel>{t('settings.servers.members.banReason')}</MenuGroupLabel>
<label className="flex flex-col gap-1 px-2 pb-1">
<input
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={reason}
onChange={(event) => setReason(event.target.value)}
maxLength={400}
placeholder={t('settings.servers.members.banReasonPlaceholder')}
data-testid={`member-ban-reason-${userId}`}
/>
</label>
<MenuItem
danger
disabled={pending}
data-testid={`member-ban-${userId}`}
onClick={() => ban.mutate()}
>
{t('settings.servers.members.ban')}
</MenuItem>
</>
) : null}
{canKick ? (
<MenuItem
danger
disabled={pending}
data-testid={`member-kick-${userId}`}
onClick={() => kick.mutate()}
>
{t('settings.servers.members.kick')}
</MenuItem>
) : null}
{error === null || error === undefined ? null : (
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
)}
</Menu>
</>
);
}
+74 -1
View File
@@ -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/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, { 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/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: guildDetail(permissions) }) }, { 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('модерация: журнал аудита', () => { describe('модерация: журнал аудита', () => {
it('показывает записи и отправляет фильтры в запрос', async () => { it('показывает записи и отправляет фильтры в запрос', async () => {
const { dialog, fetchMock } = await openServerSettings(); const { dialog, fetchMock } = await openServerSettings();