feat(web): разделы «Баны» и «Журнал аудита» в настройках сервера
Фаза 4 (AGENT.md 7.17, 7.18): модерация из интерфейса. - `api/moderation.ts`: список банов, бан, разбан, тайм-аут, кик и журнал аудита с фильтрами; - «Баны»: причина и автор каждого бана, снятие бана и пресеты тайм-аута (1/5/10/60/1440 минут) прямо из списка; ответ сервера обновляет кэш, поэтому строка исчезает без перезагрузки; - «Журнал аудита»: фильтры по действию, автору и цели уходят в запрос, кнопки «Показать» и «Сбросить»; - секции видны только с правами BAN_MEMBERS и VIEW_AUDIT_LOG (`canBanMembers`, `canViewAuditLog`); - тесты: список с причиной и автором, снятие бана с обновлением списка, тайм-аут с проверкой времени, скрытие секций без прав, фильтры в запросе.
This commit is contained in:
@@ -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<GuildBan[]> {
|
||||
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<GuildBan[]> {
|
||||
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<GuildBan[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await request<{ ok: true }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(userId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchGuildAudit(
|
||||
guildId: string,
|
||||
filters: AuditFilters,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GuildAuditEntry[]> {
|
||||
const payload = await request<{ entries: GuildAuditEntry[] }>(
|
||||
`/guilds/${encodeURIComponent(guildId)}/audit-log?${auditQuery(filters)}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return payload.entries;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Пункты настроек",
|
||||
|
||||
@@ -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 или администратор инстанса.
|
||||
|
||||
@@ -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<number>(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 (
|
||||
<SettingsSection
|
||||
title={t('settings.servers.bans.title')}
|
||||
description={t('settings.servers.bans.description')}
|
||||
>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-fg-muted">{t('settings.servers.bans.timeoutLabel')}</span>
|
||||
{TIMEOUT_PRESETS.map((minutes) => (
|
||||
<Button
|
||||
key={minutes}
|
||||
variant={timeoutMinutes === minutes ? 'primary' : 'ghost'}
|
||||
onClick={() => setTimeoutMinutes(minutes)}
|
||||
data-testid={`ban-timeout-preset-${String(minutes)}`}
|
||||
>
|
||||
{t('settings.servers.bans.timeoutMinutes', { count: minutes })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{bans.isPending ? <LoadingNotice /> : null}
|
||||
{bans.isError ? (
|
||||
<ErrorNotice className="mt-4" error={bans.error} onRetry={() => void bans.refetch()} />
|
||||
) : null}
|
||||
{unban.isError ? (
|
||||
<p className="mt-3 text-sm text-danger">{describeError(unban.error)}</p>
|
||||
) : null}
|
||||
{timeout.isError ? (
|
||||
<p className="mt-3 text-sm text-danger">{describeError(timeout.error)}</p>
|
||||
) : null}
|
||||
{bans.data !== undefined && bans.data.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-fg-muted" data-testid="guild-bans-empty">
|
||||
{t('settings.servers.bans.empty')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{bans.data === undefined || bans.data.length === 0 ? null : (
|
||||
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-bans">
|
||||
{bans.data.map((ban) => (
|
||||
<li
|
||||
key={ban.user_id}
|
||||
className="flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||
data-testid={`guild-ban-${ban.user_id}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{ban.display_name === undefined || ban.display_name === ''
|
||||
? ban.username
|
||||
: ban.display_name}{' '}
|
||||
<span className="text-fg-muted">@{ban.username}</span>
|
||||
</span>
|
||||
<span className="block truncate text-xs text-fg-muted">
|
||||
{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),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<Badge>{ban.user_id}</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={unban.isPending || timeout.isPending}
|
||||
onClick={() => unban.mutate(ban.user_id)}
|
||||
data-testid={`guild-ban-remove-${ban.user_id}`}
|
||||
>
|
||||
{t('settings.servers.bans.remove')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={unban.isPending || timeout.isPending}
|
||||
onClick={() => timeout.mutate({ userId: ban.user_id, minutes: timeoutMinutes })}
|
||||
data-testid={`guild-ban-timeout-${ban.user_id}`}
|
||||
>
|
||||
{t('settings.servers.bans.timeoutAction', { minutes: timeoutMinutes })}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
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<AuditFilters>({ 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 (
|
||||
<SettingsSection
|
||||
title={t('settings.servers.audit.title')}
|
||||
description={t('settings.servers.audit.description')}
|
||||
>
|
||||
<form
|
||||
className="mt-4 flex flex-wrap items-end gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{t('settings.servers.audit.filterAction')}
|
||||
<input
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
|
||||
value={action}
|
||||
onChange={(event) => setAction(event.target.value)}
|
||||
placeholder="member.ban"
|
||||
data-testid="audit-filter-action"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{t('settings.servers.audit.filterActor')}
|
||||
<input
|
||||
className="w-40 rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
|
||||
value={actorId}
|
||||
onChange={(event) => setActorId(event.target.value)}
|
||||
inputMode="numeric"
|
||||
data-testid="audit-filter-actor"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{t('settings.servers.audit.filterTarget')}
|
||||
<input
|
||||
className="w-40 rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
inputMode="numeric"
|
||||
data-testid="audit-filter-target"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" data-testid="audit-apply">
|
||||
{t('settings.servers.audit.apply')}
|
||||
</Button>
|
||||
<Button variant="ghost" type="button" onClick={reset} data-testid="audit-reset">
|
||||
{t('settings.servers.audit.reset')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{entries.isPending ? <LoadingNotice /> : null}
|
||||
{entries.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-4"
|
||||
error={entries.error}
|
||||
onRetry={() => void entries.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{entries.data !== undefined && entries.data.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-fg-muted" data-testid="guild-audit-empty">
|
||||
{t('settings.servers.audit.empty')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{entries.data === undefined || entries.data.length === 0 ? null : (
|
||||
<ul className="mt-4 flex flex-col gap-1 text-sm" data-testid="guild-audit">
|
||||
{entries.data.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-border/40 py-2 last:border-b-0"
|
||||
data-testid={`guild-audit-${entry.id}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="font-medium">{entry.action}</span>
|
||||
{entry.actor_name === undefined ? null : (
|
||||
<span className="text-fg-muted"> · {entry.actor_name}</span>
|
||||
)}
|
||||
{entry.target_id === undefined ? null : (
|
||||
<span className="text-fg-muted"> → {entry.target_id}</span>
|
||||
)}
|
||||
{entry.reason === undefined || entry.reason === '' ? null : (
|
||||
<span className="text-fg-muted"> · {entry.reason}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-fg-muted">
|
||||
{formatDateTime(entry.created_at, locale)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
|
||||
) : null}
|
||||
<GuildBansSection key={`${guild.id}-bans`} guildId={guild.id} canBan={canBan} />
|
||||
<GuildAuditSection key={`${guild.id}-audit`} guildId={guild.id} canView={canViewAudit} />
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
|
||||
Reference in New Issue
Block a user