feat(web): статистика, карточка пользователя и фильтры аудита в админ-панели (Фаза 7)

Новые разделы панели инстанса (AGENT.md 3.2, 7.18):

- вкладка «Статистика»: рост пользователей и сообщений по дням, размеры
  данных, топы серверов;
- карточка пользователя из списка: устройства с отзывом, серверы и роли,
  события безопасности, аудит по пользователю; отзыв устройства и сброс
  второго фактора подтверждаются step-up;
- вкладка «Аудит»: фильтры по действию, актору, цели, серверу и датам,
  пагинация и ссылка на выгрузку CSV;
- вкладка «Серверы»: поиск по названию, фильтр по владельцу и главному
  серверу, пагинация;
- ключи i18n для новых разделов (ru/en, паритет), тесты панели.
This commit is contained in:
2026-09-26 16:44:53 +03:00
parent 3dc200c196
commit 5320618d31
12 changed files with 1791 additions and 135 deletions
+257 -10
View File
@@ -18,9 +18,9 @@ export interface InstanceSettings {
export const instanceQueryKey = ['instance'] 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 instanceAuditQueryKey = (limit: number) => ['instance', 'audit', limit] as const;
export const instanceAuditActionsQueryKey = ['instance', 'audit', 'actions'] as const;
/** Публичная информация об инстансе: нужна на логине/регистрации и в /app. */
export async function fetchInstance(signal?: AbortSignal): Promise<InstanceInfo> {
@@ -49,12 +49,60 @@ export async function updateInstanceSettings(
return payload.settings;
}
export async function fetchInstanceGuilds(signal?: AbortSignal): Promise<InstanceGuild[]> {
const payload = await request<{ guilds: InstanceGuild[] }>(
'/instance/guilds',
/** Фильтры списка серверов панели (AGENT.md 7.18). */
export interface InstanceGuildFilter {
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 },
);
return payload.guilds;
return { guilds: payload.guilds ?? [], total: payload.total ?? payload.guilds?.length ?? 0 };
}
export interface InstanceUserFilter {
@@ -112,12 +160,211 @@ export async function unbanUser(userId: string): Promise<void> {
await request(`/instance/users/${encodeURIComponent(userId)}/unban`, { method: 'POST' });
}
export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<AuditEntry[]> {
const payload = await request<{ entries: AuditEntry[] }>(
`/instance/audit?limit=${String(limit)}`,
/** Фильтры журнала инстанса (AGENT.md 7.10, 7.18). */
export interface InstanceAuditFilter {
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 },
);
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` — выдать или снять права администратора. */
+12 -2
View File
@@ -212,7 +212,9 @@ export interface InstanceGuild {
name: string;
member_count: number;
owner_id: string;
owner_name?: string;
is_main: boolean;
created_at?: string;
}
/** Пользователь из админского списка GET /instance/users. */
@@ -228,12 +230,20 @@ export interface InstanceUser {
ban_reason?: string;
}
/** Запись журнала инстанса GET /instance/audit (AGENT.md 7.10, 7.18). */
export interface AuditEntry {
id: string;
actor_id: string;
actor_id?: string;
actor_instance_admin?: boolean;
action: string;
target?: string;
target_type?: string;
target_id?: string;
guild_id?: string;
reason?: string;
changes?: Record<string, unknown>;
created_at: string;
/** Краткое описание цели — собирает клиент для списка. */
target?: string;
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 type { InstanceGuild } from '@/api/types';
/** Размер страницы списка серверов: сервер принимает до 200 строк. */
const PAGE_SIZE = 50;
interface InstanceGuildsPanelProps {
guilds: InstanceGuild[];
isPending: boolean;
@@ -21,6 +24,16 @@ interface InstanceGuildsPanelProps {
onRetry: () => 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,
onRetry,
onOpenSettings,
search,
onSearchChange,
ownerId,
onOwnerIdChange,
mainOnly,
onMainOnlyChange,
total,
page,
onPageChange,
}: InstanceGuildsPanelProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -78,20 +100,51 @@ export function InstanceGuildsPanel({
});
const pending = rename.isPending || remove.isPending;
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>;
}
const lastPage = total === 0 ? 0 : Math.floor((total - 1) / PAGE_SIZE);
return (
<div className="mt-3 flex flex-col gap-2" data-testid="instance-guilds-actions">
{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">
{guilds.map((guild) => (
<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"
>
<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">
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
{t('settings.instance.memberCount', { count: guild.member_count })}
@@ -142,6 +198,29 @@ export function InstanceGuildsPanel({
</li>
))}
</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 : (
<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;
banPending: boolean;
banError?: unknown;
/** Открыть карточку пользователя: детали, устройства, аудит (AGENT.md 7.18). */
onOpenCard: (userId: string) => void;
/** Поиск по логину и имени и фильтр «только забаненные». */
search: string;
onSearchChange: (value: string) => void;
@@ -59,6 +61,7 @@ export function InstanceUsersPanel({
onUnban,
banPending,
banError,
onOpenCard,
search,
onSearchChange,
bannedOnly,
@@ -291,6 +294,13 @@ export function InstanceUsersPanel({
</form>
) : null}
<Button
variant="ghost"
data-testid={`instance-user-card-${user.id}`}
onClick={() => onOpenCard(user.id)}
>
{t('settings.instance.actions.card')}
</Button>
<Button
variant="ghost"
data-testid={`instance-user-admin-${user.id}`}
+79 -3
View File
@@ -884,7 +884,7 @@
"userId": "User ID",
"users": "Users ({{count}})",
"usersEmpty": "No users.",
"audit": "Audit log (last {{count}})",
"audit": "Audit log",
"auditEmpty": "No entries.",
"memberCount": "{{count}} members",
"adminBadge": "admin",
@@ -941,7 +941,8 @@
"limits": "Limits",
"users": "Users ({{count}})",
"guilds": "Servers ({{count}})",
"audit": "Audit"
"audit": "Audit",
"stats": "Statistics"
},
"limits": {
"title": "Limits and registration",
@@ -980,13 +981,88 @@
"banReason": "Ban reason",
"banSubmit": "Ban",
"banned": "User is banned on this instance, sessions revoked.",
"unbanned": "Ban lifted."
"unbanned": "Ban lifted.",
"card": "Card"
},
"bannedBadge": "Banned",
"search": {
"label": "Search",
"placeholder": "Username or name",
"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": {
+79 -3
View File
@@ -884,7 +884,7 @@
"userId": "ID пользователя",
"users": "Пользователи ({{count}})",
"usersEmpty": "Пользователей нет.",
"audit": "Журнал аудита (последние {{count}})",
"audit": "Журнал аудита",
"auditEmpty": "Записей нет.",
"memberCount": "{{count}} участн.",
"adminBadge": "админ",
@@ -941,7 +941,8 @@
"limits": "Лимиты",
"users": "Пользователи ({{count}})",
"guilds": "Серверы ({{count}})",
"audit": "Аудит"
"audit": "Аудит",
"stats": "Статистика"
},
"limits": {
"title": "Лимиты и регистрация",
@@ -980,13 +981,88 @@
"banReason": "Причина бана",
"banSubmit": "Забанить",
"banned": "Пользователь забанен на инстансе, сессии отозваны.",
"unbanned": "Бан снят."
"unbanned": "Бан снят.",
"card": "Карточка"
},
"bannedBadge": "Забанен",
"search": {
"label": "Поиск",
"placeholder": "Логин или имя",
"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": {
+66 -57
View File
@@ -4,12 +4,10 @@ import { useTranslation } from 'react-i18next';
import {
banUser,
fetchAudit,
fetchInstanceGuilds,
fetchInstanceSettings,
fetchInstanceUsers,
instanceAuditQueryKey,
instanceGuildsQueryKey,
instanceGuildListQueryKey,
instanceSettingsQueryKey,
instanceUserListQueryKey,
instanceUsersQueryKey,
@@ -17,29 +15,31 @@ import {
unbanUser,
type InstanceSettings,
} from '@/api/instance';
import { InstanceAuditPanel } from '@/components/instance/InstanceAuditPanel';
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
import { InstanceGuildsPanel } from '@/components/instance/InstanceGuildsPanel';
import { InstanceHealth } from '@/components/instance/InstanceHealth';
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 { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { formatDateTime } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
const AUDIT_LIMIT = 50;
/** Размер страницы списка серверов: сервер принимает до 200 строк. */
const GUILDS_PAGE_SIZE = 50;
/** Разделы админ-панели (AGENT.md 7.19). */
const tabs = ['overview', 'limits', 'users', 'guilds', 'audit'] as const;
/** Разделы админ-панели (AGENT.md 7.18, 3.2). */
const tabs = ['overview', 'stats', 'limits', 'users', 'guilds', 'audit'] as const;
type InstanceTab = (typeof tabs)[number];
/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */
/** Админский раздел инстанса: обзор, статистика, лимиты, пользователи, серверы, аудит. */
export default function InstanceSettingsPage() {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isAdmin = currentUser.data?.is_instance_admin === true;
const queryClient = useQueryClient();
@@ -49,6 +49,13 @@ export default function InstanceSettingsPage() {
// Поиск и фильтр пользователей панели (AGENT.md 7.18).
const [userSearch, setUserSearch] = useState('');
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({
queryKey: instanceSettingsQueryKey,
@@ -56,11 +63,19 @@ export default function InstanceSettingsPage() {
enabled: isAdmin,
retry: 0,
});
const guildFilter = {
query: guildSearch.trim(),
ownerId: guildOwner.trim(),
mainOnly: guildMainOnly,
limit: GUILDS_PAGE_SIZE,
offset: guildPage * GUILDS_PAGE_SIZE,
};
const guilds = useQuery({
queryKey: instanceGuildsQueryKey,
queryFn: ({ signal }) => fetchInstanceGuilds(signal),
enabled: isAdmin,
queryKey: instanceGuildListQueryKey(guildFilter),
queryFn: ({ signal }) => fetchInstanceGuilds(guildFilter, signal),
enabled: isAdmin && tab === 'guilds',
retry: 0,
placeholderData: keepPreviousData,
});
const userFilter = { query: userSearch.trim(), bannedOnly };
const users = useQuery({
@@ -72,18 +87,11 @@ export default function InstanceSettingsPage() {
// исчезает на каждом символе и набор обрывается.
placeholderData: keepPreviousData,
});
const audit = useQuery({
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
queryFn: ({ signal }) => fetchAudit(AUDIT_LIMIT, signal),
enabled: isAdmin,
retry: 0,
});
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
const metrics = useInstanceMetrics(isAdmin);
const metrics = useInstanceMetrics(isAdmin && tab === 'overview');
const afterUserChange = (): void => {
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
};
const admin = useMutation({
@@ -111,9 +119,10 @@ export default function InstanceSettingsPage() {
const tabLabels: Record<InstanceTab, string> = {
overview: t('settings.instance.tabs.overview'),
stats: t('settings.instance.tabs.stats'),
limits: t('settings.instance.tabs.limits'),
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'),
};
@@ -124,7 +133,7 @@ export default function InstanceSettingsPage() {
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
</header>
{/* Вкладки админ-панели: обзор и здоровье, лимиты, пользователи, серверы, аудит. */}
{/* Вкладки админ-панели: обзор, статистика, лимиты, пользователи, серверы, аудит. */}
<div
role="tablist"
aria-label={t('settings.instance.tabs.label')}
@@ -157,6 +166,14 @@ export default function InstanceSettingsPage() {
</>
) : 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' ? (
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.limits.title')}</h3>
@@ -186,6 +203,9 @@ export default function InstanceSettingsPage() {
{/* ID пользователей — единственное место, где он виден: раздел открыт
только администратору инстанса. */}
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
{cardUserId === null ? null : (
<InstanceUserCard userId={cardUserId} onClose={() => setCardUserId(null)} />
)}
<InstanceUsersPanel
currentUserId={currentUser.data?.id}
users={users.data?.users ?? []}
@@ -199,6 +219,7 @@ export default function InstanceSettingsPage() {
onUnban={(userId) => unban.mutate(userId)}
banPending={ban.isPending || unban.isPending}
banError={ban.error ?? unban.error}
onOpenCard={setCardUserId}
search={userSearch}
onSearchChange={setUserSearch}
bannedOnly={bannedOnly}
@@ -211,54 +232,42 @@ export default function InstanceSettingsPage() {
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.guildsTitle')}</h3>
<InstanceGuildsPanel
guilds={guilds.data ?? []}
guilds={guilds.data?.guilds ?? []}
isPending={guilds.isPending}
error={guilds.error}
onRetry={() => void guilds.refetch()}
onOpenSettings={(guildId) => {
// Настройки сервера открываются обычным разделом: у администратора
// инстанса там все права (AGENTS.md 7.19).
// инстанса там все права (AGENT.md 7.19).
selectSettingsGuild(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>
) : null}
{tab === 'audit' ? (
<Card>
<h3 className="text-base font-semibold">
{t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
</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>
)}
<h3 className="text-base font-semibold">{t('settings.instance.audit')}</h3>
<InstanceAuditPanel />
</Card>
) : null}
</div>
+5 -1
View File
@@ -152,7 +152,11 @@ describe('api-модули Фазы 1', () => {
]);
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(
'/api/v1/instance/audit?limit=50',
);
+354
View File
@@ -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,
};
}