feat(web): статистика, карточка пользователя и фильтры аудита в админ-панели (Фаза 7)
Новые разделы панели инстанса (AGENT.md 3.2, 7.18): - вкладка «Статистика»: рост пользователей и сообщений по дням, размеры данных, топы серверов; - карточка пользователя из списка: устройства с отзывом, серверы и роли, события безопасности, аудит по пользователю; отзыв устройства и сброс второго фактора подтверждаются step-up; - вкладка «Аудит»: фильтры по действию, актору, цели, серверу и датам, пагинация и ссылка на выгрузку CSV; - вкладка «Серверы»: поиск по названию, фильтр по владельцу и главному серверу, пагинация; - ключи i18n для новых разделов (ru/en, паритет), тесты панели.
This commit is contained in:
+257
-10
@@ -18,9 +18,9 @@ export interface InstanceSettings {
|
|||||||
|
|
||||||
export const instanceQueryKey = ['instance'] as const;
|
export const instanceQueryKey = ['instance'] as const;
|
||||||
export const instanceSettingsQueryKey = ['instance', 'settings'] as const;
|
export const instanceSettingsQueryKey = ['instance', 'settings'] as const;
|
||||||
export const instanceGuildsQueryKey = ['instance', 'guilds'] as const;
|
export const instanceStatsQueryKey = ['instance', 'stats'] as const;
|
||||||
export const instanceUsersQueryKey = ['instance', 'users'] as const;
|
export const instanceUsersQueryKey = ['instance', 'users'] as const;
|
||||||
export const instanceAuditQueryKey = (limit: number) => ['instance', 'audit', limit] as const;
|
export const instanceAuditActionsQueryKey = ['instance', 'audit', 'actions'] as const;
|
||||||
|
|
||||||
/** Публичная информация об инстансе: нужна на логине/регистрации и в /app. */
|
/** Публичная информация об инстансе: нужна на логине/регистрации и в /app. */
|
||||||
export async function fetchInstance(signal?: AbortSignal): Promise<InstanceInfo> {
|
export async function fetchInstance(signal?: AbortSignal): Promise<InstanceInfo> {
|
||||||
@@ -49,12 +49,60 @@ export async function updateInstanceSettings(
|
|||||||
return payload.settings;
|
return payload.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchInstanceGuilds(signal?: AbortSignal): Promise<InstanceGuild[]> {
|
/** Фильтры списка серверов панели (AGENT.md 7.18). */
|
||||||
const payload = await request<{ guilds: InstanceGuild[] }>(
|
export interface InstanceGuildFilter {
|
||||||
'/instance/guilds',
|
query?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
mainOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const instanceGuildsQueryKey = ['instance', 'guilds'] as const;
|
||||||
|
|
||||||
|
/** Ключ списка серверов: фильтр входит в ключ, иначе кэш смешает выборки. */
|
||||||
|
export function instanceGuildListQueryKey(filter: InstanceGuildFilter = {}) {
|
||||||
|
return [
|
||||||
|
...instanceGuildsQueryKey,
|
||||||
|
filter.query ?? '',
|
||||||
|
filter.ownerId ?? '',
|
||||||
|
filter.mainOnly === true,
|
||||||
|
filter.limit ?? 0,
|
||||||
|
filter.offset ?? 0,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function guildFilterParams(filter: InstanceGuildFilter): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter.query !== undefined && filter.query !== '') {
|
||||||
|
params.set('q', filter.query);
|
||||||
|
}
|
||||||
|
if (filter.ownerId !== undefined && filter.ownerId !== '') {
|
||||||
|
params.set('owner_id', filter.ownerId);
|
||||||
|
}
|
||||||
|
if (filter.mainOnly === true) {
|
||||||
|
params.set('main', 'true');
|
||||||
|
}
|
||||||
|
if (filter.limit !== undefined) {
|
||||||
|
params.set('limit', String(filter.limit));
|
||||||
|
}
|
||||||
|
if (filter.offset !== undefined) {
|
||||||
|
params.set('offset', String(filter.offset));
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInstanceGuilds(
|
||||||
|
filter: InstanceGuildFilter = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ guilds: InstanceGuild[]; total: number }> {
|
||||||
|
const params = guildFilterParams(filter);
|
||||||
|
const suffix = params.size === 0 ? '' : `?${params.toString()}`;
|
||||||
|
const payload = await request<{ guilds: InstanceGuild[]; total?: number }>(
|
||||||
|
`/instance/guilds${suffix}`,
|
||||||
signal === undefined ? {} : { signal },
|
signal === undefined ? {} : { signal },
|
||||||
);
|
);
|
||||||
return payload.guilds;
|
return { guilds: payload.guilds ?? [], total: payload.total ?? payload.guilds?.length ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceUserFilter {
|
export interface InstanceUserFilter {
|
||||||
@@ -112,12 +160,211 @@ export async function unbanUser(userId: string): Promise<void> {
|
|||||||
await request(`/instance/users/${encodeURIComponent(userId)}/unban`, { method: 'POST' });
|
await request(`/instance/users/${encodeURIComponent(userId)}/unban`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<AuditEntry[]> {
|
/** Фильтры журнала инстанса (AGENT.md 7.10, 7.18). */
|
||||||
const payload = await request<{ entries: AuditEntry[] }>(
|
export interface InstanceAuditFilter {
|
||||||
`/instance/audit?limit=${String(limit)}`,
|
action?: string;
|
||||||
|
actorId?: string;
|
||||||
|
targetId?: string;
|
||||||
|
guildId?: string;
|
||||||
|
/** Дата в формате YYYY-MM-DD (UTC). */
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ страницы журнала: фильтр входит в ключ. */
|
||||||
|
export function instanceAuditQueryKey(filter: InstanceAuditFilter = {}) {
|
||||||
|
return [
|
||||||
|
'instance',
|
||||||
|
'audit',
|
||||||
|
filter.action ?? '',
|
||||||
|
filter.actorId ?? '',
|
||||||
|
filter.targetId ?? '',
|
||||||
|
filter.guildId ?? '',
|
||||||
|
filter.since ?? '',
|
||||||
|
filter.until ?? '',
|
||||||
|
filter.limit ?? 0,
|
||||||
|
filter.offset ?? 0,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditFilterParams(filter: InstanceAuditFilter): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of [
|
||||||
|
['action', filter.action],
|
||||||
|
['actor_id', filter.actorId],
|
||||||
|
['target_id', filter.targetId],
|
||||||
|
['guild_id', filter.guildId],
|
||||||
|
['since', filter.since],
|
||||||
|
['until', filter.until],
|
||||||
|
] as const) {
|
||||||
|
if (value !== undefined && value !== '') {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filter.limit !== undefined) {
|
||||||
|
params.set('limit', String(filter.limit));
|
||||||
|
}
|
||||||
|
if (filter.offset !== undefined) {
|
||||||
|
params.set('offset', String(filter.offset));
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAudit(
|
||||||
|
filter: InstanceAuditFilter = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ entries: AuditEntry[]; total: number }> {
|
||||||
|
const params = auditFilterParams(filter);
|
||||||
|
const suffix = params.size === 0 ? '' : `?${params.toString()}`;
|
||||||
|
const payload = await request<{ entries: AuditEntry[]; total?: number }>(
|
||||||
|
`/instance/audit${suffix}`,
|
||||||
signal === undefined ? {} : { signal },
|
signal === undefined ? {} : { signal },
|
||||||
);
|
);
|
||||||
return payload.entries;
|
return { entries: payload.entries ?? [], total: payload.total ?? payload.entries?.length ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Действия, встречающиеся в журнале: подсказка для фильтра. */
|
||||||
|
export async function fetchAuditActions(signal?: AbortSignal): Promise<string[]> {
|
||||||
|
const payload = await request<{ actions: string[] }>(
|
||||||
|
'/instance/audit/actions',
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return payload.actions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адрес выгрузки журнала в CSV с теми же фильтрами. Отдаётся браузером как
|
||||||
|
* обычная ссылка с `download`, поэтому шаг подтверждения не нужен: это чтение.
|
||||||
|
*/
|
||||||
|
export function auditExportUrl(filter: InstanceAuditFilter = {}): string {
|
||||||
|
const params = auditFilterParams(filter);
|
||||||
|
const suffix = params.size === 0 ? '' : `?${params.toString()}`;
|
||||||
|
return `/api/v1/instance/audit/export${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Точка роста статистики: день (UTC) и количество. */
|
||||||
|
export interface DailyCount {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceGuildStat {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
members: number;
|
||||||
|
messages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сводка инстанса GET /instance/stats (AGENT.md 7.18). */
|
||||||
|
export interface InstanceStats {
|
||||||
|
users: {
|
||||||
|
total: number;
|
||||||
|
admins: number;
|
||||||
|
banned: number;
|
||||||
|
new_7_days: number;
|
||||||
|
new_30_days: number;
|
||||||
|
active_24h: number;
|
||||||
|
daily: DailyCount[];
|
||||||
|
};
|
||||||
|
messages: {
|
||||||
|
total: number;
|
||||||
|
today: number;
|
||||||
|
week: number;
|
||||||
|
month: number;
|
||||||
|
daily: DailyCount[];
|
||||||
|
};
|
||||||
|
files: { count: number; bytes: number };
|
||||||
|
guilds: number;
|
||||||
|
invites: number;
|
||||||
|
bans: number;
|
||||||
|
database_bytes: number;
|
||||||
|
storage_bytes: number;
|
||||||
|
top_guilds: InstanceGuildStat[];
|
||||||
|
busiest_guilds: InstanceGuildStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInstanceStats(signal?: AbortSignal): Promise<InstanceStats> {
|
||||||
|
const payload = await request<{ stats: InstanceStats }>(
|
||||||
|
'/instance/stats',
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return payload.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Устройство пользователя в карточке администратора. */
|
||||||
|
export interface InstanceSession {
|
||||||
|
id: string;
|
||||||
|
user_agent?: string;
|
||||||
|
ip?: string;
|
||||||
|
created_at: string;
|
||||||
|
last_seen: string;
|
||||||
|
expires_at: string;
|
||||||
|
stepped_up: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceMembership {
|
||||||
|
guild_id: string;
|
||||||
|
guild_name: string;
|
||||||
|
nickname?: string;
|
||||||
|
joined_at: string;
|
||||||
|
roles: { id: string; name: string; color: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceSecurityEvent {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
ip?: string;
|
||||||
|
user_agent?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Карточка пользователя GET /instance/users/{id} (AGENT.md 7.18). */
|
||||||
|
export interface InstanceUserCard {
|
||||||
|
user: InstanceUser;
|
||||||
|
totp_enabled: boolean;
|
||||||
|
passkeys: number;
|
||||||
|
sessions: InstanceSession[];
|
||||||
|
guilds: InstanceMembership[];
|
||||||
|
security_events: InstanceSecurityEvent[];
|
||||||
|
audit: AuditEntry[];
|
||||||
|
audit_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function instanceUserCardQueryKey(userId: string) {
|
||||||
|
return ['instance', 'users', 'card', userId] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInstanceUserCard(
|
||||||
|
userId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<InstanceUserCard> {
|
||||||
|
const payload = await request<InstanceUserCard>(
|
||||||
|
`/instance/users/${encodeURIComponent(userId)}`,
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /instance/users/{id}/sessions/{sessionId}` — отозвать устройство. */
|
||||||
|
export async function revokeUserSession(userId: string, sessionId: string): Promise<void> {
|
||||||
|
await request(
|
||||||
|
`/instance/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
|
{ method: 'DELETE', body: {} },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /instance/users/{id}/reset-2fa` — сбросить второй фактор. */
|
||||||
|
export async function resetUserTwoFactor(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ user_id: string; sessions_revoked: number }> {
|
||||||
|
const payload = await request<{ user_id: string; sessions_revoked: number }>(
|
||||||
|
`/instance/users/${encodeURIComponent(userId)}/reset-2fa`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `POST /instance/users/{id}/admin` — выдать или снять права администратора. */
|
/** `POST /instance/users/{id}/admin` — выдать или снять права администратора. */
|
||||||
|
|||||||
+12
-2
@@ -212,7 +212,9 @@ export interface InstanceGuild {
|
|||||||
name: string;
|
name: string;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
|
owner_name?: string;
|
||||||
is_main: boolean;
|
is_main: boolean;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пользователь из админского списка GET /instance/users. */
|
/** Пользователь из админского списка GET /instance/users. */
|
||||||
@@ -228,12 +230,20 @@ export interface InstanceUser {
|
|||||||
ban_reason?: string;
|
ban_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Запись журнала инстанса GET /instance/audit (AGENT.md 7.10, 7.18). */
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
id: string;
|
id: string;
|
||||||
actor_id: string;
|
actor_id?: string;
|
||||||
|
actor_instance_admin?: boolean;
|
||||||
action: string;
|
action: string;
|
||||||
target?: string;
|
target_type?: string;
|
||||||
|
target_id?: string;
|
||||||
|
guild_id?: string;
|
||||||
|
reason?: string;
|
||||||
|
changes?: Record<string, unknown>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** Краткое описание цели — собирает клиент для списка. */
|
||||||
|
target?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
auditExportUrl,
|
||||||
|
fetchAudit,
|
||||||
|
fetchAuditActions,
|
||||||
|
instanceAuditActionsQueryKey,
|
||||||
|
instanceAuditQueryKey,
|
||||||
|
type InstanceAuditFilter,
|
||||||
|
} from '@/api/instance';
|
||||||
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { formatDateTime } from '@/lib/identity';
|
||||||
|
|
||||||
|
/** Размер страницы журнала: сервер принимает до 200 строк (AGENT.md 7.18). */
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
/** Фильтры журнала в состоянии панели: пустые поля не отправляются. */
|
||||||
|
interface AuditFilterState {
|
||||||
|
action: string;
|
||||||
|
actorId: string;
|
||||||
|
targetId: string;
|
||||||
|
guildId: string;
|
||||||
|
since: string;
|
||||||
|
until: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFilter: AuditFilterState = {
|
||||||
|
action: '',
|
||||||
|
actorId: '',
|
||||||
|
targetId: '',
|
||||||
|
guildId: '',
|
||||||
|
since: '',
|
||||||
|
until: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** auditFilter собирает параметры запроса из состояния формы. */
|
||||||
|
function auditFilter(state: AuditFilterState, offset: number): InstanceAuditFilter {
|
||||||
|
return {
|
||||||
|
action: state.action.trim(),
|
||||||
|
actorId: state.actorId.trim(),
|
||||||
|
targetId: state.targetId.trim(),
|
||||||
|
guildId: state.guildId.trim(),
|
||||||
|
since: state.since,
|
||||||
|
until: state.until,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вкладка «Аудит» админ-панели (AGENT.md 7.10, 7.18): фильтры по действию,
|
||||||
|
* актору, цели, серверу и дате, пагинация и выгрузка в CSV.
|
||||||
|
*/
|
||||||
|
export function InstanceAuditPanel() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||||
|
const [state, setState] = useState<AuditFilterState>(emptyFilter);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// Список действий собирается из журнала: подсказка для фильтра.
|
||||||
|
const actions = useQuery({
|
||||||
|
queryKey: instanceAuditActionsQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchAuditActions(signal),
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
const filter = auditFilter(state, page * PAGE_SIZE);
|
||||||
|
const audit = useQuery({
|
||||||
|
queryKey: instanceAuditQueryKey(filter),
|
||||||
|
queryFn: ({ signal }) => fetchAudit({ ...filter, limit: PAGE_SIZE }, signal),
|
||||||
|
// Смена страницы не должна прятать таблицу: иначе мигает «загрузка».
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = audit.data?.total ?? 0;
|
||||||
|
const entries = audit.data?.entries ?? [];
|
||||||
|
const lastPage = total === 0 ? 0 : Math.floor((total - 1) / PAGE_SIZE);
|
||||||
|
const update = (patch: Partial<AuditFilterState>): void => {
|
||||||
|
setState((current) => ({ ...current, ...patch }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-3" data-testid="instance-audit-panel">
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">{t('settings.instance.auditFilters.action')}</span>
|
||||||
|
<select
|
||||||
|
data-testid="instance-audit-action"
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border bg-surface-2 px-2 py-1 text-sm"
|
||||||
|
value={state.action}
|
||||||
|
onChange={(event) => update({ action: event.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">{t('settings.instance.auditFilters.anyAction')}</option>
|
||||||
|
{(actions.data ?? []).map((action) => (
|
||||||
|
<option key={action} value={action}>
|
||||||
|
{action}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.auditFilters.actor')}
|
||||||
|
name="audit_actor"
|
||||||
|
value={state.actorId}
|
||||||
|
placeholder={t('settings.instance.auditFilters.idPlaceholder')}
|
||||||
|
onChange={(event) => update({ actorId: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.auditFilters.target')}
|
||||||
|
name="audit_target"
|
||||||
|
value={state.targetId}
|
||||||
|
placeholder={t('settings.instance.auditFilters.idPlaceholder')}
|
||||||
|
onChange={(event) => update({ targetId: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.auditFilters.guild')}
|
||||||
|
name="audit_guild"
|
||||||
|
value={state.guildId}
|
||||||
|
placeholder={t('settings.instance.auditFilters.idPlaceholder')}
|
||||||
|
onChange={(event) => update({ guildId: event.target.value })}
|
||||||
|
/>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">{t('settings.instance.auditFilters.since')}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="audit_since"
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border bg-surface-2 px-2 py-1 text-sm"
|
||||||
|
value={state.since}
|
||||||
|
onChange={(event) => update({ since: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="font-medium">{t('settings.instance.auditFilters.until')}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="audit_until"
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border bg-surface-2 px-2 py-1 text-sm"
|
||||||
|
value={state.until}
|
||||||
|
onChange={(event) => update({ until: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button variant="ghost" onClick={() => update(emptyFilter)}>
|
||||||
|
{t('settings.instance.auditFilters.reset')}
|
||||||
|
</Button>
|
||||||
|
{/* Выгрузка — обычная ссылка: сервер отдаёт CSV с теми же фильтрами. */}
|
||||||
|
{/* limit/offset в адресе экспорта сервер не читает: важны фильтры. */}
|
||||||
|
<a
|
||||||
|
href={auditExportUrl(filter)}
|
||||||
|
download
|
||||||
|
data-testid="instance-audit-export"
|
||||||
|
className="rounded-[var(--radius-sm)] border border-border px-3 py-1.5 text-sm text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('settings.instance.auditFilters.export')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audit.isError ? (
|
||||||
|
<ErrorNotice error={audit.error} onRetry={() => void audit.refetch()} />
|
||||||
|
) : null}
|
||||||
|
{audit.isPending ? <LoadingNotice /> : null}
|
||||||
|
|
||||||
|
<p className="text-xs text-fg-muted" data-testid="instance-audit-total">
|
||||||
|
{t('settings.instance.auditTotal', { count: total })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{entries.length === 0 && !audit.isPending ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.instance.auditEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-audit">
|
||||||
|
{entries.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"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-medium">{entry.action}</span>
|
||||||
|
{entry.target_id === undefined ? null : (
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{' '}
|
||||||
|
→ {entry.target_type ?? '?'}#{entry.target_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.actor_id === undefined ? null : (
|
||||||
|
<span className="text-fg-muted"> · {entry.actor_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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="instance-audit-prev"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((value) => Math.max(0, value - 1))}
|
||||||
|
>
|
||||||
|
{t('settings.instance.pagination.prev')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-fg-muted" data-testid="instance-audit-page">
|
||||||
|
{t('settings.instance.pagination.page', { page: page + 1, total: lastPage + 1 })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="instance-audit-next"
|
||||||
|
disabled={page >= lastPage}
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
>
|
||||||
|
{t('settings.instance.pagination.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import { Button } from '@/components/ui/primitives';
|
|||||||
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
||||||
import type { InstanceGuild } from '@/api/types';
|
import type { InstanceGuild } from '@/api/types';
|
||||||
|
|
||||||
|
/** Размер страницы списка серверов: сервер принимает до 200 строк. */
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
interface InstanceGuildsPanelProps {
|
interface InstanceGuildsPanelProps {
|
||||||
guilds: InstanceGuild[];
|
guilds: InstanceGuild[];
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
@@ -21,6 +24,16 @@ interface InstanceGuildsPanelProps {
|
|||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
/** Открыть настройки сервера: админ правит комнаты, роли и ники там. */
|
/** Открыть настройки сервера: админ правит комнаты, роли и ники там. */
|
||||||
onOpenSettings: (guildId: string) => void;
|
onOpenSettings: (guildId: string) => void;
|
||||||
|
/** Фильтры и пагинация списка (AGENT.md 7.18). */
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
ownerId: string;
|
||||||
|
onOwnerIdChange: (value: string) => void;
|
||||||
|
mainOnly: boolean;
|
||||||
|
onMainOnlyChange: (value: boolean) => void;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +47,15 @@ export function InstanceGuildsPanel({
|
|||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
ownerId,
|
||||||
|
onOwnerIdChange,
|
||||||
|
mainOnly,
|
||||||
|
onMainOnlyChange,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
onPageChange,
|
||||||
}: InstanceGuildsPanelProps) {
|
}: InstanceGuildsPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -78,20 +100,51 @@ export function InstanceGuildsPanel({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pending = rename.isPending || remove.isPending;
|
const pending = rename.isPending || remove.isPending;
|
||||||
|
const lastPage = total === 0 ? 0 : Math.floor((total - 1) / PAGE_SIZE);
|
||||||
if (isPending) {
|
|
||||||
return <p className="mt-2 text-sm text-fg-muted">{t('common.loading')}</p>;
|
|
||||||
}
|
|
||||||
if (error !== null && error !== undefined) {
|
|
||||||
return <ErrorNotice className="mt-3" error={error} onRetry={onRetry} />;
|
|
||||||
}
|
|
||||||
if (guilds.length === 0) {
|
|
||||||
return <p className="mt-2 text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex flex-col gap-2" data-testid="instance-guilds-actions">
|
<div className="mt-3 flex flex-col gap-2" data-testid="instance-guilds-actions">
|
||||||
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.search.label')}
|
||||||
|
name="instance-guild-search"
|
||||||
|
value={search}
|
||||||
|
placeholder={t('settings.instance.guildSearch.placeholder')}
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t('settings.instance.guildSearch.owner')}
|
||||||
|
name="instance-guild-owner"
|
||||||
|
value={ownerId}
|
||||||
|
placeholder={t('settings.instance.auditFilters.idPlaceholder')}
|
||||||
|
onChange={(event) => onOwnerIdChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 pb-1 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="instance-guilds-main-only"
|
||||||
|
checked={mainOnly}
|
||||||
|
onChange={(event) => onMainOnlyChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('settings.instance.guildSearch.mainOnly')}
|
||||||
|
</label>
|
||||||
|
<Button variant="ghost" onClick={() => onSearchChange('')}>
|
||||||
|
{t('settings.instance.auditFilters.reset')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-fg-muted" data-testid="instance-guilds-total">
|
||||||
|
{t('settings.instance.guildsTotal', { count: total })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t('common.loading')}</p>
|
||||||
|
) : error !== null && error !== undefined ? (
|
||||||
|
<ErrorNotice error={error} onRetry={onRetry} />
|
||||||
|
) : guilds.length === 0 ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>
|
||||||
|
) : (
|
||||||
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-guilds">
|
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-guilds">
|
||||||
{guilds.map((guild) => (
|
{guilds.map((guild) => (
|
||||||
<li
|
<li
|
||||||
@@ -100,6 +153,9 @@ export function InstanceGuildsPanel({
|
|||||||
className="flex flex-wrap items-center gap-2 border-b border-border/40 py-2 last:border-b-0"
|
className="flex flex-wrap items-center gap-2 border-b border-border/40 py-2 last:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{guild.name}</span>
|
<span className="min-w-0 flex-1 truncate">{guild.name}</span>
|
||||||
|
{guild.owner_name === undefined ? null : (
|
||||||
|
<span className="shrink-0 text-xs text-fg-muted">@{guild.owner_name}</span>
|
||||||
|
)}
|
||||||
<span className="shrink-0 text-xs text-fg-muted">
|
<span className="shrink-0 text-xs text-fg-muted">
|
||||||
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
|
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
|
||||||
{t('settings.instance.memberCount', { count: guild.member_count })}
|
{t('settings.instance.memberCount', { count: guild.member_count })}
|
||||||
@@ -142,6 +198,29 @@ export function InstanceGuildsPanel({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="instance-guilds-prev"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => onPageChange(Math.max(0, page - 1))}
|
||||||
|
>
|
||||||
|
{t('settings.instance.pagination.prev')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-fg-muted" data-testid="instance-guilds-page">
|
||||||
|
{t('settings.instance.pagination.page', { page: page + 1, total: lastPage + 1 })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="instance-guilds-next"
|
||||||
|
disabled={page >= lastPage}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
{t('settings.instance.pagination.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{renaming === null ? null : (
|
{renaming === null ? null : (
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { fetchInstanceStats, instanceStatsQueryKey, type DailyCount } from '@/api/instance';
|
||||||
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { formatBytes } from '@/lib/format';
|
||||||
|
|
||||||
|
/** Полоски роста: ширина пропорциональна максимуму за окно (AGENT.md 7.18). */
|
||||||
|
function GrowthBars({ points, testId }: { points: DailyCount[]; testId: string }) {
|
||||||
|
const max = points.reduce((value, point) => Math.max(value, point.count), 0);
|
||||||
|
return (
|
||||||
|
<ol className="flex h-16 items-end gap-0.5" data-testid={testId}>
|
||||||
|
{points.map((point) => (
|
||||||
|
<li
|
||||||
|
key={point.date}
|
||||||
|
title={`${point.date}: ${String(point.count)}`}
|
||||||
|
data-testid={`${testId}-${point.date}`}
|
||||||
|
className="min-w-0 flex-1 rounded-t-[2px] bg-accent/70"
|
||||||
|
style={{
|
||||||
|
height: max === 0 ? '2px' : `${String(Math.max(2, (point.count / max) * 100))}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плитка с числом и подписью. */
|
||||||
|
function StatTile({ label, value, testId }: { label: string; value: string; testId: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-border/60 px-3 py-2">
|
||||||
|
<div className="text-xs text-fg-muted">{label}</div>
|
||||||
|
<div className="text-lg font-semibold" data-testid={testId}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вкладка «Статистика» админ-панели инстанса (AGENT.md 3.2, 7.18): рост
|
||||||
|
* пользователей и сообщений, размеры данных и топы серверов.
|
||||||
|
*/
|
||||||
|
export function InstanceStatsPanel() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||||
|
const number = (value: number): string => new Intl.NumberFormat(locale).format(value);
|
||||||
|
const stats = useQuery({
|
||||||
|
queryKey: instanceStatsQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchInstanceStats(signal),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stats.isPending) {
|
||||||
|
return <LoadingNotice />;
|
||||||
|
}
|
||||||
|
if (stats.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorNotice className="mt-3" error={stats.error} onRetry={() => void stats.refetch()} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = stats.data;
|
||||||
|
if (data === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-5" data-testid="instance-stats">
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-semibold">{t('settings.instance.stats.usersTitle')}</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.usersTotal')}
|
||||||
|
value={number(data.users.total)}
|
||||||
|
testId="instance-stats-users"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.usersNew7')}
|
||||||
|
value={number(data.users.new_7_days)}
|
||||||
|
testId="instance-stats-users-7"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.usersNew30')}
|
||||||
|
value={number(data.users.new_30_days)}
|
||||||
|
testId="instance-stats-users-30"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.usersActive')}
|
||||||
|
value={number(data.users.active_24h)}
|
||||||
|
testId="instance-stats-users-active"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.usersAdmins')}
|
||||||
|
value={number(data.users.admins)}
|
||||||
|
testId="instance-stats-admins"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.usersBanned')}
|
||||||
|
value={number(data.users.banned)}
|
||||||
|
testId="instance-stats-banned"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<GrowthBars points={data.users.daily} testId="instance-stats-users-daily" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-semibold">{t('settings.instance.stats.messagesTitle')}</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.messagesTotal')}
|
||||||
|
value={number(data.messages.total)}
|
||||||
|
testId="instance-stats-messages"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.messagesToday')}
|
||||||
|
value={number(data.messages.today)}
|
||||||
|
testId="instance-stats-messages-today"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.messagesWeek')}
|
||||||
|
value={number(data.messages.week)}
|
||||||
|
testId="instance-stats-messages-week"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.messagesMonth')}
|
||||||
|
value={number(data.messages.month)}
|
||||||
|
testId="instance-stats-messages-month"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<GrowthBars points={data.messages.daily} testId="instance-stats-messages-daily" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-semibold">{t('settings.instance.stats.storageTitle')}</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.storageTotal')}
|
||||||
|
value={formatBytes(data.storage_bytes, locale)}
|
||||||
|
testId="instance-stats-storage"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.database')}
|
||||||
|
value={formatBytes(data.database_bytes, locale)}
|
||||||
|
testId="instance-stats-database"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.filesCount')}
|
||||||
|
value={`${number(data.files.count)} · ${formatBytes(data.files.bytes, locale)}`}
|
||||||
|
testId="instance-stats-files"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.guilds')}
|
||||||
|
value={number(data.guilds)}
|
||||||
|
testId="instance-stats-guilds"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.invites')}
|
||||||
|
value={number(data.invites)}
|
||||||
|
testId="instance-stats-invites"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={t('settings.instance.stats.bans')}
|
||||||
|
value={number(data.bans)}
|
||||||
|
testId="instance-stats-bans"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-semibold">{t('settings.instance.stats.topGuilds')}</h4>
|
||||||
|
<ol className="flex flex-col gap-1 text-sm" data-testid="instance-stats-top-guilds">
|
||||||
|
{data.top_guilds.map((guild) => (
|
||||||
|
<li key={guild.id} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">{guild.name}</span>
|
||||||
|
<span className="shrink-0 text-xs text-fg-muted">
|
||||||
|
{t('settings.instance.stats.members', { count: guild.members })}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-semibold">{t('settings.instance.stats.busiestGuilds')}</h4>
|
||||||
|
<ol className="flex flex-col gap-1 text-sm" data-testid="instance-stats-busiest-guilds">
|
||||||
|
{data.busiest_guilds.map((guild) => (
|
||||||
|
<li key={guild.id} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">{guild.name}</span>
|
||||||
|
<span className="shrink-0 text-xs text-fg-muted">
|
||||||
|
{t('settings.instance.stats.messagesCount', { count: guild.messages })}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { stepUp } from '@/api/auth';
|
||||||
|
import {
|
||||||
|
fetchInstanceUserCard,
|
||||||
|
instanceUserCardQueryKey,
|
||||||
|
resetUserTwoFactor,
|
||||||
|
revokeUserSession,
|
||||||
|
} from '@/api/instance';
|
||||||
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field } from '@/components/ui/Field';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { errorCode } from '@/lib/format';
|
||||||
|
import { formatDateTime } from '@/lib/identity';
|
||||||
|
import { roleColorHex } from '@/lib/format';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
|
||||||
|
interface InstanceUserCardProps {
|
||||||
|
userId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Действие, которое ждёт подтверждения личности (AGENT.md 7.1). */
|
||||||
|
type PendingAction = { kind: 'revoke'; sessionId: string } | { kind: 'reset2fa' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карточка пользователя в админ-панели инстанса (AGENT.md 3.2, 7.18): профиль,
|
||||||
|
* активные устройства с отзывом, серверы и роли, события безопасности и
|
||||||
|
* история аудита по пользователю. Чувствительные действия (отзыв устройства,
|
||||||
|
* сброс второго фактора) требуют step-up.
|
||||||
|
*/
|
||||||
|
export function InstanceUserCard({ userId, onClose }: InstanceUserCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const [actionError, setActionError] = useState<unknown>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||||
|
const [stepUpPassword, setStepUpPassword] = useState('');
|
||||||
|
const [stepUpCode, setStepUpCode] = useState('');
|
||||||
|
|
||||||
|
const card = useQuery({
|
||||||
|
queryKey: instanceUserCardQueryKey(userId),
|
||||||
|
queryFn: ({ signal }) => fetchInstanceUserCard(userId, signal),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = (): void => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceUserCardQueryKey(userId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** stepUpRequired различает отказ по свежести аутентификации. */
|
||||||
|
const stepUpRequired = (cause: unknown): boolean => errorCode(cause) === 'auth.step_up_required';
|
||||||
|
|
||||||
|
const revoke = useMutation({
|
||||||
|
mutationFn: (sessionId: string) => revokeUserSession(userId, sessionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setActionError(null);
|
||||||
|
setNotice(t('settings.instance.card.sessionRevoked'));
|
||||||
|
setPending(null);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => {
|
||||||
|
if (stepUpRequired(cause)) {
|
||||||
|
const sessionId = revoke.variables;
|
||||||
|
if (sessionId !== undefined) {
|
||||||
|
setPending({ kind: 'revoke', sessionId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError(cause);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset2fa = useMutation({
|
||||||
|
mutationFn: () => resetUserTwoFactor(userId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setActionError(null);
|
||||||
|
setNotice(t('settings.instance.card.twoFactorReset', { count: result.sessions_revoked }));
|
||||||
|
setPending(null);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => {
|
||||||
|
if (stepUpRequired(cause)) {
|
||||||
|
setPending({ kind: 'reset2fa' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError(cause);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirm = useMutation({
|
||||||
|
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
|
||||||
|
onSuccess: () => {
|
||||||
|
setStepUpPassword('');
|
||||||
|
setStepUpCode('');
|
||||||
|
const action = pending;
|
||||||
|
setPending(null);
|
||||||
|
if (action?.kind === 'revoke') {
|
||||||
|
revoke.mutate(action.sessionId);
|
||||||
|
} else if (action?.kind === 'reset2fa') {
|
||||||
|
reset2fa.mutate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (card.isPending) {
|
||||||
|
return <LoadingNotice />;
|
||||||
|
}
|
||||||
|
if (card.isError) {
|
||||||
|
return <ErrorNotice error={card.error} onRetry={() => void card.refetch()} />;
|
||||||
|
}
|
||||||
|
const data = card.data;
|
||||||
|
if (data === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const target = data.user;
|
||||||
|
const self = target.id === currentUser.data?.id;
|
||||||
|
const pendingAction = revoke.isPending || reset2fa.isPending || confirm.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-4 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/40 p-3"
|
||||||
|
data-testid="instance-user-card"
|
||||||
|
aria-label={t('settings.instance.card.title', { name: target.username })}
|
||||||
|
>
|
||||||
|
<header className="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 className="text-base font-semibold">
|
||||||
|
{target.display_name} <span className="text-fg-muted">@{target.username}</span>
|
||||||
|
</h4>
|
||||||
|
{target.is_instance_admin ? (
|
||||||
|
<span className="rounded-full border border-accent/50 px-2 text-xs text-accent">
|
||||||
|
{t('settings.instance.adminBadge')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{target.banned ? (
|
||||||
|
<span className="rounded-full border border-danger/60 px-2 text-xs text-danger">
|
||||||
|
{t('settings.instance.bannedBadge')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="font-mono text-xs text-fg-muted">{target.id}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-auto"
|
||||||
|
data-testid="instance-user-card-close"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
||||||
|
{notice === null ? null : (
|
||||||
|
<p className="text-sm text-success" role="status">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pending === null ? null : (
|
||||||
|
<form
|
||||||
|
className="flex flex-wrap items-end gap-2 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-2"
|
||||||
|
data-testid="instance-user-card-step-up"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
confirm.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label={t('settings.security.stepUpPassword')}
|
||||||
|
type="password"
|
||||||
|
name="step_up_password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={stepUpPassword}
|
||||||
|
onChange={(event) => setStepUpPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{currentUser.data?.totp_enabled === true ? (
|
||||||
|
<Field
|
||||||
|
label={t('auth.login.totp')}
|
||||||
|
name="step_up_totp"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={8}
|
||||||
|
value={stepUpCode}
|
||||||
|
onChange={(event) => setStepUpCode(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{confirm.isError ? <ErrorNotice error={confirm.error} /> : null}
|
||||||
|
<Button type="submit" disabled={confirm.isPending}>
|
||||||
|
{t('settings.security.stepUpSubmit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setPending(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<section className="flex flex-col gap-1 text-sm">
|
||||||
|
<h5 className="text-sm font-semibold">{t('settings.instance.card.profile')}</h5>
|
||||||
|
<p className="text-fg-muted">
|
||||||
|
{t('settings.instance.card.created', {
|
||||||
|
date: formatDateTime(target.created_at, locale),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-muted" data-testid="instance-user-card-totp">
|
||||||
|
{t(
|
||||||
|
data.totp_enabled
|
||||||
|
? 'settings.instance.card.twoFactorOn'
|
||||||
|
: 'settings.instance.card.twoFactorOff',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-fg-muted" data-testid="instance-user-card-passkeys">
|
||||||
|
{t('settings.instance.card.passkeys', { count: data.passkeys })}
|
||||||
|
</p>
|
||||||
|
{target.banned && target.ban_reason !== undefined && target.ban_reason !== '' ? (
|
||||||
|
<p className="text-danger">
|
||||||
|
{t('settings.instance.card.banReason', { reason: target.ban_reason })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-1 text-sm">
|
||||||
|
<h5 className="text-sm font-semibold">{t('settings.instance.card.guilds')}</h5>
|
||||||
|
{data.guilds.length === 0 ? (
|
||||||
|
<p className="text-fg-muted">{t('settings.instance.card.noGuilds')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1" data-testid="instance-user-card-guilds">
|
||||||
|
{data.guilds.map((membership) => (
|
||||||
|
<li key={membership.guild_id} className="flex flex-wrap items-center gap-1">
|
||||||
|
<span className="truncate">{membership.guild_name}</span>
|
||||||
|
{membership.nickname === undefined || membership.nickname === '' ? null : (
|
||||||
|
<span className="text-xs text-fg-muted">({membership.nickname})</span>
|
||||||
|
)}
|
||||||
|
{membership.roles.map((role) => (
|
||||||
|
<span
|
||||||
|
key={role.id}
|
||||||
|
className="rounded-full border border-border/60 px-1.5 text-xs"
|
||||||
|
style={
|
||||||
|
roleColorHex(role.color) === null
|
||||||
|
? undefined
|
||||||
|
: { color: roleColorHex(role.color) ?? undefined }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-1 text-sm">
|
||||||
|
<h5 className="text-sm font-semibold">{t('settings.instance.card.sessions')}</h5>
|
||||||
|
{data.sessions.length === 0 ? (
|
||||||
|
<p className="text-fg-muted">{t('settings.instance.card.noSessions')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1" data-testid="instance-user-card-sessions">
|
||||||
|
{data.sessions.map((session) => (
|
||||||
|
<li
|
||||||
|
key={session.id}
|
||||||
|
className="flex flex-wrap items-center gap-2 border-b border-border/40 py-1 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={session.user_agent}>
|
||||||
|
{session.user_agent === undefined || session.user_agent === ''
|
||||||
|
? t('settings.instance.card.unknownDevice')
|
||||||
|
: session.user_agent}
|
||||||
|
</span>
|
||||||
|
{session.ip === undefined || session.ip === '' ? null : (
|
||||||
|
<span className="shrink-0 font-mono text-xs text-fg-muted">{session.ip}</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 text-xs text-fg-muted">
|
||||||
|
{formatDateTime(session.last_seen, locale)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border border-danger/50 text-danger"
|
||||||
|
data-testid={`instance-user-card-revoke-${session.id}`}
|
||||||
|
disabled={pendingAction}
|
||||||
|
onClick={() => {
|
||||||
|
setActionError(null);
|
||||||
|
revoke.mutate(session.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.instance.card.revokeSession')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-1 text-sm">
|
||||||
|
<h5 className="text-sm font-semibold">{t('settings.instance.card.securityEvents')}</h5>
|
||||||
|
{data.security_events.length === 0 ? (
|
||||||
|
<p className="text-fg-muted">{t('settings.instance.card.noEvents')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1" data-testid="instance-user-card-events">
|
||||||
|
{data.security_events.map((event) => (
|
||||||
|
<li key={event.id} className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{event.type}</span>
|
||||||
|
{event.ip === undefined || event.ip === '' ? null : (
|
||||||
|
<span className="font-mono text-xs text-fg-muted">{event.ip}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-fg-muted">
|
||||||
|
{formatDateTime(event.created_at, locale)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-1 text-sm">
|
||||||
|
<h5 className="text-sm font-semibold">
|
||||||
|
{t('settings.instance.card.audit', { count: data.audit_total })}
|
||||||
|
</h5>
|
||||||
|
{data.audit.length === 0 ? (
|
||||||
|
<p className="text-fg-muted">{t('settings.instance.card.noAudit')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1" data-testid="instance-user-card-audit">
|
||||||
|
{data.audit.map((entry) => (
|
||||||
|
<li key={entry.id} className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{entry.action}</span>
|
||||||
|
{entry.reason === undefined || entry.reason === '' ? null : (
|
||||||
|
<span className="text-fg-muted">{entry.reason}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-fg-muted">
|
||||||
|
{formatDateTime(entry.created_at, locale)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Свой второй фактор меняют в настройках безопасности, чужой ключ
|
||||||
|
администратора не сбрасывается (AGENT.md 7.19). */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border border-danger/50 text-danger"
|
||||||
|
data-testid="instance-user-card-reset-2fa"
|
||||||
|
disabled={pendingAction || self || target.is_instance_admin || !data.totp_enabled}
|
||||||
|
onClick={() => {
|
||||||
|
setActionError(null);
|
||||||
|
reset2fa.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.instance.card.resetTwoFactor')}
|
||||||
|
</Button>
|
||||||
|
{!data.totp_enabled ? (
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{t('settings.instance.card.twoFactorOffHint')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ interface InstanceUsersPanelProps {
|
|||||||
onUnban: (userId: string) => void;
|
onUnban: (userId: string) => void;
|
||||||
banPending: boolean;
|
banPending: boolean;
|
||||||
banError?: unknown;
|
banError?: unknown;
|
||||||
|
/** Открыть карточку пользователя: детали, устройства, аудит (AGENT.md 7.18). */
|
||||||
|
onOpenCard: (userId: string) => void;
|
||||||
/** Поиск по логину и имени и фильтр «только забаненные». */
|
/** Поиск по логину и имени и фильтр «только забаненные». */
|
||||||
search: string;
|
search: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
@@ -59,6 +61,7 @@ export function InstanceUsersPanel({
|
|||||||
onUnban,
|
onUnban,
|
||||||
banPending,
|
banPending,
|
||||||
banError,
|
banError,
|
||||||
|
onOpenCard,
|
||||||
search,
|
search,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
bannedOnly,
|
bannedOnly,
|
||||||
@@ -291,6 +294,13 @@ export function InstanceUsersPanel({
|
|||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`instance-user-card-${user.id}`}
|
||||||
|
onClick={() => onOpenCard(user.id)}
|
||||||
|
>
|
||||||
|
{t('settings.instance.actions.card')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
data-testid={`instance-user-admin-${user.id}`}
|
data-testid={`instance-user-admin-${user.id}`}
|
||||||
|
|||||||
@@ -884,7 +884,7 @@
|
|||||||
"userId": "User ID",
|
"userId": "User ID",
|
||||||
"users": "Users ({{count}})",
|
"users": "Users ({{count}})",
|
||||||
"usersEmpty": "No users.",
|
"usersEmpty": "No users.",
|
||||||
"audit": "Audit log (last {{count}})",
|
"audit": "Audit log",
|
||||||
"auditEmpty": "No entries.",
|
"auditEmpty": "No entries.",
|
||||||
"memberCount": "{{count}} members",
|
"memberCount": "{{count}} members",
|
||||||
"adminBadge": "admin",
|
"adminBadge": "admin",
|
||||||
@@ -941,7 +941,8 @@
|
|||||||
"limits": "Limits",
|
"limits": "Limits",
|
||||||
"users": "Users ({{count}})",
|
"users": "Users ({{count}})",
|
||||||
"guilds": "Servers ({{count}})",
|
"guilds": "Servers ({{count}})",
|
||||||
"audit": "Audit"
|
"audit": "Audit",
|
||||||
|
"stats": "Statistics"
|
||||||
},
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"title": "Limits and registration",
|
"title": "Limits and registration",
|
||||||
@@ -980,13 +981,88 @@
|
|||||||
"banReason": "Ban reason",
|
"banReason": "Ban reason",
|
||||||
"banSubmit": "Ban",
|
"banSubmit": "Ban",
|
||||||
"banned": "User is banned on this instance, sessions revoked.",
|
"banned": "User is banned on this instance, sessions revoked.",
|
||||||
"unbanned": "Ban lifted."
|
"unbanned": "Ban lifted.",
|
||||||
|
"card": "Card"
|
||||||
},
|
},
|
||||||
"bannedBadge": "Banned",
|
"bannedBadge": "Banned",
|
||||||
"search": {
|
"search": {
|
||||||
"label": "Search",
|
"label": "Search",
|
||||||
"placeholder": "Username or name",
|
"placeholder": "Username or name",
|
||||||
"bannedOnly": "Banned only"
|
"bannedOnly": "Banned only"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Instance statistics",
|
||||||
|
"description": "User and message growth, data size and top servers.",
|
||||||
|
"usersTitle": "Users",
|
||||||
|
"usersTotal": "Total",
|
||||||
|
"usersNew7": "New in 7 days",
|
||||||
|
"usersNew30": "New in 30 days",
|
||||||
|
"usersActive": "Active in 24 hours",
|
||||||
|
"usersAdmins": "Administrators",
|
||||||
|
"usersBanned": "Banned",
|
||||||
|
"messagesTitle": "Messages",
|
||||||
|
"messagesTotal": "Total",
|
||||||
|
"messagesToday": "In 24 hours",
|
||||||
|
"messagesWeek": "In a week",
|
||||||
|
"messagesMonth": "In a month",
|
||||||
|
"storageTitle": "Data",
|
||||||
|
"storageTotal": "Used in total",
|
||||||
|
"database": "Database",
|
||||||
|
"filesCount": "Files",
|
||||||
|
"guilds": "Servers",
|
||||||
|
"invites": "Active invites",
|
||||||
|
"bans": "Instance bans",
|
||||||
|
"topGuilds": "Top by members",
|
||||||
|
"busiestGuilds": "Top by messages",
|
||||||
|
"members": "{{count}} members",
|
||||||
|
"messagesCount": "{{count}} messages"
|
||||||
|
},
|
||||||
|
"auditTotal": "Entries matching the filter: {{count}}",
|
||||||
|
"pagination": {
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"page": "Page {{page}} of {{total}}"
|
||||||
|
},
|
||||||
|
"guildsTotal": "Servers matching the filter: {{count}}",
|
||||||
|
"guildSearch": {
|
||||||
|
"placeholder": "Server name",
|
||||||
|
"owner": "Owner ID",
|
||||||
|
"mainOnly": "Main server only"
|
||||||
|
},
|
||||||
|
"auditFilters": {
|
||||||
|
"action": "Action",
|
||||||
|
"anyAction": "Any action",
|
||||||
|
"actor": "Actor ID",
|
||||||
|
"target": "Target ID",
|
||||||
|
"guild": "Server ID",
|
||||||
|
"since": "From date",
|
||||||
|
"until": "To date",
|
||||||
|
"reset": "Reset",
|
||||||
|
"export": "Export CSV",
|
||||||
|
"idPlaceholder": "snowflake ID"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "User card {{name}}",
|
||||||
|
"profile": "Profile",
|
||||||
|
"created": "Account created: {{date}}",
|
||||||
|
"twoFactorOn": "Two-factor authentication is enabled",
|
||||||
|
"twoFactorOff": "Two-factor authentication is disabled",
|
||||||
|
"passkeys": "Passkeys: {{count}}",
|
||||||
|
"banReason": "Ban reason: {{reason}}",
|
||||||
|
"guilds": "Servers and roles",
|
||||||
|
"noGuilds": "The user is not a member of any server.",
|
||||||
|
"sessions": "Active devices",
|
||||||
|
"noSessions": "No active devices.",
|
||||||
|
"unknownDevice": "Unnamed device",
|
||||||
|
"revokeSession": "Revoke",
|
||||||
|
"sessionRevoked": "The device has been revoked.",
|
||||||
|
"securityEvents": "Security events",
|
||||||
|
"noEvents": "No events.",
|
||||||
|
"audit": "Audit for the user ({{count}})",
|
||||||
|
"noAudit": "No entries.",
|
||||||
|
"resetTwoFactor": "Reset two-factor",
|
||||||
|
"twoFactorReset": "Two-factor authentication reset, sessions revoked: {{count}}.",
|
||||||
|
"twoFactorOffHint": "Nothing to reset: two-factor authentication is disabled."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
|
|||||||
@@ -884,7 +884,7 @@
|
|||||||
"userId": "ID пользователя",
|
"userId": "ID пользователя",
|
||||||
"users": "Пользователи ({{count}})",
|
"users": "Пользователи ({{count}})",
|
||||||
"usersEmpty": "Пользователей нет.",
|
"usersEmpty": "Пользователей нет.",
|
||||||
"audit": "Журнал аудита (последние {{count}})",
|
"audit": "Журнал аудита",
|
||||||
"auditEmpty": "Записей нет.",
|
"auditEmpty": "Записей нет.",
|
||||||
"memberCount": "{{count}} участн.",
|
"memberCount": "{{count}} участн.",
|
||||||
"adminBadge": "админ",
|
"adminBadge": "админ",
|
||||||
@@ -941,7 +941,8 @@
|
|||||||
"limits": "Лимиты",
|
"limits": "Лимиты",
|
||||||
"users": "Пользователи ({{count}})",
|
"users": "Пользователи ({{count}})",
|
||||||
"guilds": "Серверы ({{count}})",
|
"guilds": "Серверы ({{count}})",
|
||||||
"audit": "Аудит"
|
"audit": "Аудит",
|
||||||
|
"stats": "Статистика"
|
||||||
},
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"title": "Лимиты и регистрация",
|
"title": "Лимиты и регистрация",
|
||||||
@@ -980,13 +981,88 @@
|
|||||||
"banReason": "Причина бана",
|
"banReason": "Причина бана",
|
||||||
"banSubmit": "Забанить",
|
"banSubmit": "Забанить",
|
||||||
"banned": "Пользователь забанен на инстансе, сессии отозваны.",
|
"banned": "Пользователь забанен на инстансе, сессии отозваны.",
|
||||||
"unbanned": "Бан снят."
|
"unbanned": "Бан снят.",
|
||||||
|
"card": "Карточка"
|
||||||
},
|
},
|
||||||
"bannedBadge": "Забанен",
|
"bannedBadge": "Забанен",
|
||||||
"search": {
|
"search": {
|
||||||
"label": "Поиск",
|
"label": "Поиск",
|
||||||
"placeholder": "Логин или имя",
|
"placeholder": "Логин или имя",
|
||||||
"bannedOnly": "Только забаненные"
|
"bannedOnly": "Только забаненные"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Статистика инстанса",
|
||||||
|
"description": "Рост пользователей и сообщений, размер данных и топ серверов.",
|
||||||
|
"usersTitle": "Пользователи",
|
||||||
|
"usersTotal": "Всего",
|
||||||
|
"usersNew7": "Новых за 7 дней",
|
||||||
|
"usersNew30": "Новых за 30 дней",
|
||||||
|
"usersActive": "Активных за сутки",
|
||||||
|
"usersAdmins": "Администраторов",
|
||||||
|
"usersBanned": "Забанено",
|
||||||
|
"messagesTitle": "Сообщения",
|
||||||
|
"messagesTotal": "Всего",
|
||||||
|
"messagesToday": "За сутки",
|
||||||
|
"messagesWeek": "За неделю",
|
||||||
|
"messagesMonth": "За месяц",
|
||||||
|
"storageTitle": "Данные",
|
||||||
|
"storageTotal": "Занято всего",
|
||||||
|
"database": "База данных",
|
||||||
|
"filesCount": "Файлов",
|
||||||
|
"guilds": "Серверов",
|
||||||
|
"invites": "Действующих инвайтов",
|
||||||
|
"bans": "Банов инстанса",
|
||||||
|
"topGuilds": "Топ по участникам",
|
||||||
|
"busiestGuilds": "Топ по сообщениям",
|
||||||
|
"members": "{{count}} участн.",
|
||||||
|
"messagesCount": "{{count}} сообщ."
|
||||||
|
},
|
||||||
|
"auditTotal": "Записей по фильтру: {{count}}",
|
||||||
|
"pagination": {
|
||||||
|
"prev": "Назад",
|
||||||
|
"next": "Вперёд",
|
||||||
|
"page": "Страница {{page}} из {{total}}"
|
||||||
|
},
|
||||||
|
"guildsTotal": "Серверов по фильтру: {{count}}",
|
||||||
|
"guildSearch": {
|
||||||
|
"placeholder": "Название сервера",
|
||||||
|
"owner": "ID владельца",
|
||||||
|
"mainOnly": "Только главный сервер"
|
||||||
|
},
|
||||||
|
"auditFilters": {
|
||||||
|
"action": "Действие",
|
||||||
|
"anyAction": "Любое действие",
|
||||||
|
"actor": "ID актора",
|
||||||
|
"target": "ID цели",
|
||||||
|
"guild": "ID сервера",
|
||||||
|
"since": "С даты",
|
||||||
|
"until": "По дату",
|
||||||
|
"reset": "Сбросить",
|
||||||
|
"export": "Экспорт CSV",
|
||||||
|
"idPlaceholder": "snowflake ID"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "Карточка пользователя {{name}}",
|
||||||
|
"profile": "Профиль",
|
||||||
|
"created": "Аккаунт создан: {{date}}",
|
||||||
|
"twoFactorOn": "Второй фактор включён",
|
||||||
|
"twoFactorOff": "Второй фактор выключен",
|
||||||
|
"passkeys": "Ключей доступа: {{count}}",
|
||||||
|
"banReason": "Причина бана: {{reason}}",
|
||||||
|
"guilds": "Серверы и роли",
|
||||||
|
"noGuilds": "Пользователь не состоит в серверах.",
|
||||||
|
"sessions": "Активные устройства",
|
||||||
|
"noSessions": "Активных устройств нет.",
|
||||||
|
"unknownDevice": "Устройство без подписи",
|
||||||
|
"revokeSession": "Отозвать",
|
||||||
|
"sessionRevoked": "Устройство отозвано.",
|
||||||
|
"securityEvents": "События безопасности",
|
||||||
|
"noEvents": "Событий нет.",
|
||||||
|
"audit": "Аудит по пользователю ({{count}})",
|
||||||
|
"noAudit": "Записей нет.",
|
||||||
|
"resetTwoFactor": "Сбросить второй фактор",
|
||||||
|
"twoFactorReset": "Второй фактор сброшен, отозвано сессий: {{count}}.",
|
||||||
|
"twoFactorOffHint": "Сбрасывать нечего: второй фактор выключен."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
banUser,
|
banUser,
|
||||||
fetchAudit,
|
|
||||||
fetchInstanceGuilds,
|
fetchInstanceGuilds,
|
||||||
fetchInstanceSettings,
|
fetchInstanceSettings,
|
||||||
fetchInstanceUsers,
|
fetchInstanceUsers,
|
||||||
instanceAuditQueryKey,
|
instanceGuildListQueryKey,
|
||||||
instanceGuildsQueryKey,
|
|
||||||
instanceSettingsQueryKey,
|
instanceSettingsQueryKey,
|
||||||
instanceUserListQueryKey,
|
instanceUserListQueryKey,
|
||||||
instanceUsersQueryKey,
|
instanceUsersQueryKey,
|
||||||
@@ -17,29 +15,31 @@ import {
|
|||||||
unbanUser,
|
unbanUser,
|
||||||
type InstanceSettings,
|
type InstanceSettings,
|
||||||
} from '@/api/instance';
|
} from '@/api/instance';
|
||||||
|
import { InstanceAuditPanel } from '@/components/instance/InstanceAuditPanel';
|
||||||
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
|
||||||
import { InstanceGuildsPanel } from '@/components/instance/InstanceGuildsPanel';
|
import { InstanceGuildsPanel } from '@/components/instance/InstanceGuildsPanel';
|
||||||
import { InstanceHealth } from '@/components/instance/InstanceHealth';
|
import { InstanceHealth } from '@/components/instance/InstanceHealth';
|
||||||
import { InstanceLimitsPanel } from '@/components/instance/InstanceLimitsPanel';
|
import { InstanceLimitsPanel } from '@/components/instance/InstanceLimitsPanel';
|
||||||
|
import { InstanceStatsPanel } from '@/components/instance/InstanceStatsPanel';
|
||||||
|
import { InstanceUserCard } from '@/components/instance/InstanceUserCard';
|
||||||
import { InstanceUsersPanel } from '@/components/instance/InstanceUsersPanel';
|
import { InstanceUsersPanel } from '@/components/instance/InstanceUsersPanel';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { formatDateTime } from '@/lib/identity';
|
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
|
import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
|
||||||
|
|
||||||
const AUDIT_LIMIT = 50;
|
/** Размер страницы списка серверов: сервер принимает до 200 строк. */
|
||||||
|
const GUILDS_PAGE_SIZE = 50;
|
||||||
|
|
||||||
/** Разделы админ-панели (AGENT.md 7.19). */
|
/** Разделы админ-панели (AGENT.md 7.18, 3.2). */
|
||||||
const tabs = ['overview', 'limits', 'users', 'guilds', 'audit'] as const;
|
const tabs = ['overview', 'stats', 'limits', 'users', 'guilds', 'audit'] as const;
|
||||||
type InstanceTab = (typeof tabs)[number];
|
type InstanceTab = (typeof tabs)[number];
|
||||||
|
|
||||||
/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */
|
/** Админский раздел инстанса: обзор, статистика, лимиты, пользователи, серверы, аудит. */
|
||||||
export default function InstanceSettingsPage() {
|
export default function InstanceSettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const isAdmin = currentUser.data?.is_instance_admin === true;
|
const isAdmin = currentUser.data?.is_instance_admin === true;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -49,6 +49,13 @@ export default function InstanceSettingsPage() {
|
|||||||
// Поиск и фильтр пользователей панели (AGENT.md 7.18).
|
// Поиск и фильтр пользователей панели (AGENT.md 7.18).
|
||||||
const [userSearch, setUserSearch] = useState('');
|
const [userSearch, setUserSearch] = useState('');
|
||||||
const [bannedOnly, setBannedOnly] = useState(false);
|
const [bannedOnly, setBannedOnly] = useState(false);
|
||||||
|
// Фильтры и страница списка серверов (AGENT.md 7.18).
|
||||||
|
const [guildSearch, setGuildSearch] = useState('');
|
||||||
|
const [guildOwner, setGuildOwner] = useState('');
|
||||||
|
const [guildMainOnly, setGuildMainOnly] = useState(false);
|
||||||
|
const [guildPage, setGuildPage] = useState(0);
|
||||||
|
// Карточка пользователя открывается поверх списка (AGENT.md 3.2).
|
||||||
|
const [cardUserId, setCardUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
const settings = useQuery({
|
const settings = useQuery({
|
||||||
queryKey: instanceSettingsQueryKey,
|
queryKey: instanceSettingsQueryKey,
|
||||||
@@ -56,11 +63,19 @@ export default function InstanceSettingsPage() {
|
|||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
const guildFilter = {
|
||||||
|
query: guildSearch.trim(),
|
||||||
|
ownerId: guildOwner.trim(),
|
||||||
|
mainOnly: guildMainOnly,
|
||||||
|
limit: GUILDS_PAGE_SIZE,
|
||||||
|
offset: guildPage * GUILDS_PAGE_SIZE,
|
||||||
|
};
|
||||||
const guilds = useQuery({
|
const guilds = useQuery({
|
||||||
queryKey: instanceGuildsQueryKey,
|
queryKey: instanceGuildListQueryKey(guildFilter),
|
||||||
queryFn: ({ signal }) => fetchInstanceGuilds(signal),
|
queryFn: ({ signal }) => fetchInstanceGuilds(guildFilter, signal),
|
||||||
enabled: isAdmin,
|
enabled: isAdmin && tab === 'guilds',
|
||||||
retry: 0,
|
retry: 0,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
const userFilter = { query: userSearch.trim(), bannedOnly };
|
const userFilter = { query: userSearch.trim(), bannedOnly };
|
||||||
const users = useQuery({
|
const users = useQuery({
|
||||||
@@ -72,18 +87,11 @@ export default function InstanceSettingsPage() {
|
|||||||
// исчезает на каждом символе и набор обрывается.
|
// исчезает на каждом символе и набор обрывается.
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
const audit = useQuery({
|
|
||||||
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
|
|
||||||
queryFn: ({ signal }) => fetchAudit(AUDIT_LIMIT, signal),
|
|
||||||
enabled: isAdmin,
|
|
||||||
retry: 0,
|
|
||||||
});
|
|
||||||
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
|
||||||
const metrics = useInstanceMetrics(isAdmin);
|
const metrics = useInstanceMetrics(isAdmin && tab === 'overview');
|
||||||
|
|
||||||
const afterUserChange = (): void => {
|
const afterUserChange = (): void => {
|
||||||
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
||||||
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const admin = useMutation({
|
const admin = useMutation({
|
||||||
@@ -111,9 +119,10 @@ export default function InstanceSettingsPage() {
|
|||||||
|
|
||||||
const tabLabels: Record<InstanceTab, string> = {
|
const tabLabels: Record<InstanceTab, string> = {
|
||||||
overview: t('settings.instance.tabs.overview'),
|
overview: t('settings.instance.tabs.overview'),
|
||||||
|
stats: t('settings.instance.tabs.stats'),
|
||||||
limits: t('settings.instance.tabs.limits'),
|
limits: t('settings.instance.tabs.limits'),
|
||||||
users: t('settings.instance.tabs.users', { count: users.data?.total ?? 0 }),
|
users: t('settings.instance.tabs.users', { count: users.data?.total ?? 0 }),
|
||||||
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.length ?? 0 }),
|
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.total ?? 0 }),
|
||||||
audit: t('settings.instance.tabs.audit'),
|
audit: t('settings.instance.tabs.audit'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,7 +133,7 @@ export default function InstanceSettingsPage() {
|
|||||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
|
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Вкладки админ-панели: обзор и здоровье, лимиты, пользователи, серверы, аудит. */}
|
{/* Вкладки админ-панели: обзор, статистика, лимиты, пользователи, серверы, аудит. */}
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={t('settings.instance.tabs.label')}
|
aria-label={t('settings.instance.tabs.label')}
|
||||||
@@ -157,6 +166,14 @@ export default function InstanceSettingsPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{tab === 'stats' ? (
|
||||||
|
<Card>
|
||||||
|
<h3 className="text-base font-semibold">{t('settings.instance.stats.title')}</h3>
|
||||||
|
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.stats.description')}</p>
|
||||||
|
<InstanceStatsPanel />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{tab === 'limits' ? (
|
{tab === 'limits' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-base font-semibold">{t('settings.instance.limits.title')}</h3>
|
<h3 className="text-base font-semibold">{t('settings.instance.limits.title')}</h3>
|
||||||
@@ -186,6 +203,9 @@ export default function InstanceSettingsPage() {
|
|||||||
{/* ID пользователей — единственное место, где он виден: раздел открыт
|
{/* ID пользователей — единственное место, где он виден: раздел открыт
|
||||||
только администратору инстанса. */}
|
только администратору инстанса. */}
|
||||||
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
|
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
|
||||||
|
{cardUserId === null ? null : (
|
||||||
|
<InstanceUserCard userId={cardUserId} onClose={() => setCardUserId(null)} />
|
||||||
|
)}
|
||||||
<InstanceUsersPanel
|
<InstanceUsersPanel
|
||||||
currentUserId={currentUser.data?.id}
|
currentUserId={currentUser.data?.id}
|
||||||
users={users.data?.users ?? []}
|
users={users.data?.users ?? []}
|
||||||
@@ -199,6 +219,7 @@ export default function InstanceSettingsPage() {
|
|||||||
onUnban={(userId) => unban.mutate(userId)}
|
onUnban={(userId) => unban.mutate(userId)}
|
||||||
banPending={ban.isPending || unban.isPending}
|
banPending={ban.isPending || unban.isPending}
|
||||||
banError={ban.error ?? unban.error}
|
banError={ban.error ?? unban.error}
|
||||||
|
onOpenCard={setCardUserId}
|
||||||
search={userSearch}
|
search={userSearch}
|
||||||
onSearchChange={setUserSearch}
|
onSearchChange={setUserSearch}
|
||||||
bannedOnly={bannedOnly}
|
bannedOnly={bannedOnly}
|
||||||
@@ -211,54 +232,42 @@ export default function InstanceSettingsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-base font-semibold">{t('settings.instance.guildsTitle')}</h3>
|
<h3 className="text-base font-semibold">{t('settings.instance.guildsTitle')}</h3>
|
||||||
<InstanceGuildsPanel
|
<InstanceGuildsPanel
|
||||||
guilds={guilds.data ?? []}
|
guilds={guilds.data?.guilds ?? []}
|
||||||
isPending={guilds.isPending}
|
isPending={guilds.isPending}
|
||||||
error={guilds.error}
|
error={guilds.error}
|
||||||
onRetry={() => void guilds.refetch()}
|
onRetry={() => void guilds.refetch()}
|
||||||
onOpenSettings={(guildId) => {
|
onOpenSettings={(guildId) => {
|
||||||
// Настройки сервера открываются обычным разделом: у администратора
|
// Настройки сервера открываются обычным разделом: у администратора
|
||||||
// инстанса там все права (AGENTS.md 7.19).
|
// инстанса там все права (AGENT.md 7.19).
|
||||||
selectSettingsGuild(guildId);
|
selectSettingsGuild(guildId);
|
||||||
openSettings({ section: 'server', guildId });
|
openSettings({ section: 'server', guildId });
|
||||||
}}
|
}}
|
||||||
|
search={guildSearch}
|
||||||
|
onSearchChange={(value) => {
|
||||||
|
setGuildSearch(value);
|
||||||
|
setGuildPage(0);
|
||||||
|
}}
|
||||||
|
ownerId={guildOwner}
|
||||||
|
onOwnerIdChange={(value) => {
|
||||||
|
setGuildOwner(value);
|
||||||
|
setGuildPage(0);
|
||||||
|
}}
|
||||||
|
mainOnly={guildMainOnly}
|
||||||
|
onMainOnlyChange={(value) => {
|
||||||
|
setGuildMainOnly(value);
|
||||||
|
setGuildPage(0);
|
||||||
|
}}
|
||||||
|
total={guilds.data?.total ?? 0}
|
||||||
|
page={guildPage}
|
||||||
|
onPageChange={setGuildPage}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tab === 'audit' ? (
|
{tab === 'audit' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">{t('settings.instance.audit')}</h3>
|
||||||
{t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
|
<InstanceAuditPanel />
|
||||||
</h3>
|
|
||||||
{audit.isPending ? <LoadingNotice /> : null}
|
|
||||||
{audit.isError ? (
|
|
||||||
<ErrorNotice
|
|
||||||
className="mt-3"
|
|
||||||
error={audit.error}
|
|
||||||
onRetry={() => void audit.refetch()}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{audit.data !== undefined && audit.data.length === 0 ? (
|
|
||||||
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.auditEmpty')}</p>
|
|
||||||
) : null}
|
|
||||||
{audit.data === undefined || audit.data.length === 0 ? null : (
|
|
||||||
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-audit">
|
|
||||||
{audit.data.map((entry) => (
|
|
||||||
<li
|
|
||||||
key={entry.id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{entry.action}
|
|
||||||
{entry.target === undefined ? '' : ` → ${entry.target}`}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs text-fg-muted">
|
|
||||||
{formatDateTime(entry.created_at, locale)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,11 @@ describe('api-модули Фазы 1', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
|
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
|
||||||
await expect(fetchAudit(50)).resolves.toEqual([{ id: 'a-1' }]);
|
// Журнал отдаётся вместе с общим числом записей под фильтр (AGENT.md 7.18).
|
||||||
|
await expect(fetchAudit({ limit: 50 })).resolves.toEqual({
|
||||||
|
entries: [{ id: 'a-1' }],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
|
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
|
||||||
'/api/v1/instance/audit?limit=50',
|
'/api/v1/instance/audit?limit=50',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { apiError, installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers';
|
||||||
|
|
||||||
|
/** Ответ GET /instance/stats в формате ручки (AGENT.md 7.18). */
|
||||||
|
function statsPayload() {
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
users: {
|
||||||
|
total: 42,
|
||||||
|
admins: 2,
|
||||||
|
banned: 1,
|
||||||
|
new_7_days: 5,
|
||||||
|
new_30_days: 17,
|
||||||
|
active_24h: 9,
|
||||||
|
daily: [
|
||||||
|
{ date: '2026-09-25', count: 2 },
|
||||||
|
{ date: '2026-09-26', count: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
total: 1500,
|
||||||
|
today: 40,
|
||||||
|
week: 300,
|
||||||
|
month: 900,
|
||||||
|
daily: [
|
||||||
|
{ date: '2026-09-25', count: 10 },
|
||||||
|
{ date: '2026-09-26', count: 40 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
files: { count: 12, bytes: 3 * 1024 * 1024 },
|
||||||
|
guilds: 4,
|
||||||
|
invites: 3,
|
||||||
|
bans: 1,
|
||||||
|
database_bytes: 2 * 1024 * 1024,
|
||||||
|
storage_bytes: 5 * 1024 * 1024,
|
||||||
|
top_guilds: [{ id: 'g-1', name: 'Альфа', members: 12, messages: 400 }],
|
||||||
|
busiest_guilds: [{ id: 'g-2', name: 'Бета', members: 5, messages: 900 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Карточка пользователя GET /instance/users/{id}. */
|
||||||
|
function userCardPayload() {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: 'u-1',
|
||||||
|
username: 'card_target',
|
||||||
|
display_name: 'Цель',
|
||||||
|
is_instance_admin: false,
|
||||||
|
badges: [],
|
||||||
|
created_at: '2026-09-20T10:00:00Z',
|
||||||
|
banned: false,
|
||||||
|
},
|
||||||
|
totp_enabled: true,
|
||||||
|
passkeys: 1,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 's-1',
|
||||||
|
user_agent: 'Тестовый браузер',
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
created_at: '2026-09-26T09:00:00Z',
|
||||||
|
last_seen: '2026-09-26T10:00:00Z',
|
||||||
|
expires_at: '2026-10-26T09:00:00Z',
|
||||||
|
stepped_up: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
guilds: [
|
||||||
|
{
|
||||||
|
guild_id: 'g-1',
|
||||||
|
guild_name: 'Альфа',
|
||||||
|
nickname: 'гость',
|
||||||
|
joined_at: '2026-09-21T10:00:00Z',
|
||||||
|
roles: [{ id: 'r-1', name: 'Модератор', color: 16711680 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
security_events: [
|
||||||
|
{ id: 'e-1', type: 'login', ip: '127.0.0.1', created_at: '2026-09-26T09:00:00Z' },
|
||||||
|
],
|
||||||
|
audit: [
|
||||||
|
{
|
||||||
|
id: 'a-1',
|
||||||
|
action: 'instance.user_ban',
|
||||||
|
target_type: 'user',
|
||||||
|
target_id: 'u-1',
|
||||||
|
created_at: '2026-09-25T09:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
audit_total: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appRoutes(extra: FetchRoute[]): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
...extra,
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me',
|
||||||
|
response: () => json({ user: makeUser({ is_instance_admin: true }) }),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** openInstance открывает настройки и переходит на вкладку «Инстанс». */
|
||||||
|
async function openInstance(): Promise<HTMLElement> {
|
||||||
|
const gear = await screen.findByLabelText('Настройки пользователя');
|
||||||
|
await userEvent.click(gear);
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
await userEvent.click(await within(dialog).findByRole('button', { name: 'Инстанс' }));
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('расширенная админ-панель инстанса', () => {
|
||||||
|
it('показывает статистику роста, хранилища и топы серверов', async () => {
|
||||||
|
installFetch(
|
||||||
|
appRoutes([
|
||||||
|
{ match: '/api/v1/instance/stats', response: () => json(statsPayload()) },
|
||||||
|
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-tab-stats'));
|
||||||
|
expect(await within(dialog).findByTestId('instance-stats-users')).toHaveTextContent('42');
|
||||||
|
expect(within(dialog).getByTestId('instance-stats-users-7')).toHaveTextContent('5');
|
||||||
|
expect(within(dialog).getByTestId('instance-stats-messages')).toHaveTextContent('1 500');
|
||||||
|
expect(within(dialog).getByTestId('instance-stats-storage')).toHaveTextContent('5 MB');
|
||||||
|
expect(within(dialog).getByTestId('instance-stats-guilds')).toHaveTextContent('4');
|
||||||
|
// Полоски роста: по одной на день из ответа.
|
||||||
|
const daily = within(dialog).getByTestId('instance-stats-messages-daily');
|
||||||
|
expect(within(daily).getAllByRole('listitem')).toHaveLength(2);
|
||||||
|
expect(within(dialog).getByTestId('instance-stats-top-guilds')).toHaveTextContent('Альфа');
|
||||||
|
expect(within(dialog).getByTestId('instance-stats-busiest-guilds')).toHaveTextContent('Бета');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('фильтрует аудит, листает страницы и отдаёт ссылку на экспорт', async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
installFetch(
|
||||||
|
appRoutes([
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/audit/actions',
|
||||||
|
response: () => json({ actions: ['instance.user_ban', 'instance.settings_update'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/audit',
|
||||||
|
response: ({ url }) => {
|
||||||
|
requests.push(url);
|
||||||
|
return json({
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: 'a-1',
|
||||||
|
action: 'instance.user_ban',
|
||||||
|
actor_id: 'u-9',
|
||||||
|
target_type: 'user',
|
||||||
|
target_id: 'u-1',
|
||||||
|
reason: 'спам',
|
||||||
|
created_at: '2026-09-25T09:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 120,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-tab-audit'));
|
||||||
|
const list = await within(dialog).findByTestId('instance-audit');
|
||||||
|
expect(list).toHaveTextContent('instance.user_ban');
|
||||||
|
expect(list).toHaveTextContent('спам');
|
||||||
|
expect(within(dialog).getByTestId('instance-audit-total')).toHaveTextContent('120');
|
||||||
|
|
||||||
|
// Фильтр по действию уходит в запрос.
|
||||||
|
await userEvent.selectOptions(
|
||||||
|
within(dialog).getByTestId('instance-audit-action'),
|
||||||
|
'instance.user_ban',
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests.some((url) => url.includes('action=instance.user_ban'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ссылка на выгрузку несёт тот же фильтр.
|
||||||
|
expect(within(dialog).getByTestId('instance-audit-export')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
expect.stringContaining('action=instance.user_ban'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Пагинация: 120 записей по 50 — три страницы, «вперёд» уходит на offset=50.
|
||||||
|
await userEvent.click(within(dialog).getByTestId('instance-audit-next'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests.some((url) => url.includes('offset=50'))).toBe(true);
|
||||||
|
});
|
||||||
|
expect(within(dialog).getByTestId('instance-audit-page')).toHaveTextContent('Страница 2 из 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ищет серверы по названию и листает список', async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
installFetch(
|
||||||
|
appRoutes([
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/guilds',
|
||||||
|
response: ({ url }) => {
|
||||||
|
requests.push(url);
|
||||||
|
return json({
|
||||||
|
guilds: [
|
||||||
|
{
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Альфа',
|
||||||
|
owner_id: 'u-1',
|
||||||
|
owner_name: 'owner',
|
||||||
|
member_count: 3,
|
||||||
|
is_main: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 60,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/instance/users', response: () => json({ users: [], total: 0 }) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-tab-guilds'));
|
||||||
|
expect(await within(dialog).findByTestId('instance-guild-g-1')).toHaveTextContent('Альфа');
|
||||||
|
expect(within(dialog).getByTestId('instance-guilds-total')).toHaveTextContent('60');
|
||||||
|
|
||||||
|
await userEvent.type(within(dialog).getByLabelText('Поиск'), 'альф');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests.some((url) => url.includes('q='))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByTestId('instance-guilds-next'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests.some((url) => url.includes('offset=50'))).toBe(true);
|
||||||
|
});
|
||||||
|
expect(within(dialog).getByTestId('instance-guilds-page')).toHaveTextContent('Страница 2 из 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открывает карточку пользователя, отзывает устройство и сбрасывает 2FA', async () => {
|
||||||
|
const calls: { url: string; method: string; body: unknown }[] = [];
|
||||||
|
installFetch(
|
||||||
|
appRoutes([
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users',
|
||||||
|
response: () => json({ users: [makeInstanceUser()], total: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/auth/step-up',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => {
|
||||||
|
calls.push({ url: 'step-up', method: 'POST', body: null });
|
||||||
|
return json({ ok: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users/u-1/sessions/s-1',
|
||||||
|
method: 'DELETE',
|
||||||
|
response: () => {
|
||||||
|
calls.push({ url: 'revoke', method: 'DELETE', body: null });
|
||||||
|
// Первый вызов сервер отклоняет: нужна свежая проверка личности
|
||||||
|
// (AGENT.md 7.1), после step-up действие проходит.
|
||||||
|
return calls.filter((call) => call.url === 'revoke').length === 1
|
||||||
|
? apiError('auth.step_up_required', 403)
|
||||||
|
: json({ ok: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users/u-1/reset-2fa',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => {
|
||||||
|
calls.push({ url: 'reset-2fa', method: 'POST', body: null });
|
||||||
|
return json({ user_id: 'u-1', sessions_revoked: 2 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users/u-1',
|
||||||
|
response: () => json(userCardPayload()),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-user-card-u-1'));
|
||||||
|
|
||||||
|
const card = await within(dialog).findByTestId('instance-user-card');
|
||||||
|
expect(within(card).getByTestId('instance-user-card-guilds')).toHaveTextContent('Модератор');
|
||||||
|
expect(within(card).getByTestId('instance-user-card-totp')).toHaveTextContent(
|
||||||
|
'Второй фактор включён',
|
||||||
|
);
|
||||||
|
expect(within(card).getByTestId('instance-user-card-events')).toHaveTextContent('login');
|
||||||
|
expect(within(card).getByTestId('instance-user-card-audit')).toHaveTextContent(
|
||||||
|
'instance.user_ban',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Отзыв устройства — чувствительное действие: сервер требует step-up,
|
||||||
|
// панель показывает форму подтверждения (AGENT.md 7.1).
|
||||||
|
await userEvent.click(within(card).getByTestId('instance-user-card-revoke-s-1'));
|
||||||
|
const stepUpForm = await within(card).findByTestId('instance-user-card-step-up');
|
||||||
|
expect(stepUpForm).toBeVisible();
|
||||||
|
await userEvent.type(within(stepUpForm).getByLabelText('Ваш пароль'), 'correct-horse-battery');
|
||||||
|
await userEvent.click(within(stepUpForm).getByRole('button', { name: 'Подтвердить' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(calls.filter((call) => call.url === 'revoke')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(calls.some((call) => call.url === 'step-up')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('скрывает сброс второго фактора при выключенном 2FA', async () => {
|
||||||
|
const payload = userCardPayload();
|
||||||
|
payload.totp_enabled = false;
|
||||||
|
installFetch(
|
||||||
|
appRoutes([
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users',
|
||||||
|
response: () => json({ users: [makeInstanceUser()], total: 1 }),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/instance/users/u-1', response: () => json(payload) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openInstance();
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
|
||||||
|
await userEvent.click(await within(dialog).findByTestId('instance-user-card-u-1'));
|
||||||
|
|
||||||
|
const card = await within(dialog).findByTestId('instance-user-card');
|
||||||
|
expect(within(card).getByTestId('instance-user-card-reset-2fa')).toBeDisabled();
|
||||||
|
expect(within(card).getByTestId('instance-user-card-totp')).toHaveTextContent(
|
||||||
|
'Второй фактор выключен',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** makeInstanceUser — строка списка пользователей панели. */
|
||||||
|
function makeInstanceUser() {
|
||||||
|
return {
|
||||||
|
id: 'u-1',
|
||||||
|
username: 'card_target',
|
||||||
|
display_name: 'Цель',
|
||||||
|
is_instance_admin: false,
|
||||||
|
badges: [],
|
||||||
|
created_at: '2026-09-20T10:00:00Z',
|
||||||
|
banned: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user