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 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
@@ -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,70 +100,127 @@ 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} />}
|
||||
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-guilds">
|
||||
{guilds.map((guild) => (
|
||||
<li
|
||||
key={guild.id}
|
||||
data-testid={`instance-guild-${guild.id}`}
|
||||
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="shrink-0 text-xs text-fg-muted">
|
||||
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
|
||||
{t('settings.instance.memberCount', { count: guild.member_count })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid={`instance-guild-open-${guild.id}`}
|
||||
onClick={() => onOpenSettings(guild.id)}
|
||||
<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
|
||||
key={guild.id}
|
||||
data-testid={`instance-guild-${guild.id}`}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-border/40 py-2 last:border-b-0"
|
||||
>
|
||||
{t('settings.instance.actions.openSettings')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid={`instance-guild-rename-${guild.id}`}
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setRenaming(guild);
|
||||
setName(guild.name);
|
||||
}}
|
||||
>
|
||||
{t('settings.instance.actions.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border border-danger/50 text-danger"
|
||||
data-testid={`instance-guild-delete-${guild.id}`}
|
||||
disabled={pending || guild.is_main}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t('settings.instance.actions.deleteGuildConfirm', { name: guild.name }),
|
||||
)
|
||||
) {
|
||||
remove.mutate(guild.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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 })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid={`instance-guild-open-${guild.id}`}
|
||||
onClick={() => onOpenSettings(guild.id)}
|
||||
>
|
||||
{t('settings.instance.actions.openSettings')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid={`instance-guild-rename-${guild.id}`}
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setRenaming(guild);
|
||||
setName(guild.name);
|
||||
}}
|
||||
>
|
||||
{t('settings.instance.actions.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border border-danger/50 text-danger"
|
||||
data-testid={`instance-guild-delete-${guild.id}`}
|
||||
disabled={pending || guild.is_main}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t('settings.instance.actions.deleteGuildConfirm', { name: guild.name }),
|
||||
)
|
||||
) {
|
||||
remove.mutate(guild.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</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}`}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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