feat(web): меню модерации участника в списке сервера
Банить и исключать нужно того, кто прямо сейчас в сервере, а не только разбирать готовый список банов (AGENT.md 7.17). - `MemberModerationMenu`: тайм-аут (1/10/60/1440 минут и снятие), бан с причиной и исключение; пункты скрыты без прав KICK_MEMBERS/BAN_MEMBERS/TIMEOUT_MEMBERS, себя модерировать нельзя; - после действия обновляются списки участников и банов (без F5); - тесты: бан с причиной, тайм-аут, исключение и скрытие меню без прав.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -815,7 +815,14 @@
|
||||
"description": "Первые {{limit}} участников сервера.",
|
||||
"empty": "Участники не найдены.",
|
||||
"roles": "Ролей: {{roles}}",
|
||||
"more": "Полный список участников с ролями появится в следующих фазах."
|
||||
"more": "Полный список участников с ролями появится в следующих фазах.",
|
||||
"moderate": "Модерировать {{name}}",
|
||||
"timeout": "Тайм-аут",
|
||||
"timeoutClear": "Снять тайм-аут",
|
||||
"banReason": "Причина бана",
|
||||
"banReasonPlaceholder": "необязательно",
|
||||
"ban": "Забанить",
|
||||
"kick": "Исключить"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Роли",
|
||||
|
||||
@@ -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({
|
||||
<Badge>
|
||||
{t('settings.servers.members.roles', { roles: member.role_ids.length })}
|
||||
</Badge>
|
||||
{member.user_id === currentUserId ? null : (
|
||||
<MemberModerationMenu
|
||||
guildId={guildId}
|
||||
userId={member.user_id}
|
||||
userName={name}
|
||||
canKick={canKick}
|
||||
canBan={canBan}
|
||||
canTimeout={canTimeout}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 }[] }) {
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
<GuildMembersSection
|
||||
guildId={guild.id}
|
||||
members={members.data}
|
||||
isPending={members.isPending}
|
||||
error={members.error}
|
||||
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} />
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user