From 5320618d31040cc2e4ad1ca8d9186d9571dd93ac Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 16:44:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BA=D0=B0,=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD-=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BB=D0=B8=20(=D0=A4=D0=B0=D0=B7=D0=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новые разделы панели инстанса (AGENT.md 3.2, 7.18): - вкладка «Статистика»: рост пользователей и сообщений по дням, размеры данных, топы серверов; - карточка пользователя из списка: устройства с отзывом, серверы и роли, события безопасности, аудит по пользователю; отзыв устройства и сброс второго фактора подтверждаются step-up; - вкладка «Аудит»: фильтры по действию, актору, цели, серверу и датам, пагинация и ссылка на выгрузку CSV; - вкладка «Серверы»: поиск по названию, фильтр по владельцу и главному серверу, пагинация; - ключи i18n для новых разделов (ru/en, паритет), тесты панели. --- web/src/api/instance.ts | 267 ++++++++++++- web/src/api/types.ts | 14 +- .../instance/InstanceAuditPanel.tsx | 227 +++++++++++ .../instance/InstanceGuildsPanel.tsx | 197 +++++++--- .../instance/InstanceStatsPanel.tsx | 199 ++++++++++ .../components/instance/InstanceUserCard.tsx | 365 ++++++++++++++++++ .../instance/InstanceUsersPanel.tsx | 10 + web/src/i18n/locales/en.json | 82 +++- web/src/i18n/locales/ru.json | 82 +++- .../pages/settings/InstanceSettingsPage.tsx | 123 +++--- web/tests/api.test.ts | 6 +- web/tests/instancePanel.test.tsx | 354 +++++++++++++++++ 12 files changed, 1791 insertions(+), 135 deletions(-) create mode 100644 web/src/components/instance/InstanceAuditPanel.tsx create mode 100644 web/src/components/instance/InstanceStatsPanel.tsx create mode 100644 web/src/components/instance/InstanceUserCard.tsx create mode 100644 web/tests/instancePanel.test.tsx diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts index d290c9c..6a1f958 100644 --- a/web/src/api/instance.ts +++ b/web/src/api/instance.ts @@ -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 { @@ -49,12 +49,60 @@ export async function updateInstanceSettings( return payload.settings; } -export async function fetchInstanceGuilds(signal?: AbortSignal): Promise { - 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 { await request(`/instance/users/${encodeURIComponent(userId)}/unban`, { method: 'POST' }); } -export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise { - 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 { + 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 { + 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; + 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 { + const payload = await request( + `/instance/users/${encodeURIComponent(userId)}`, + signal === undefined ? {} : { signal }, + ); + return payload; +} + +/** `DELETE /instance/users/{id}/sessions/{sessionId}` — отозвать устройство. */ +export async function revokeUserSession(userId: string, sessionId: string): Promise { + 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` — выдать или снять права администратора. */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d704b80..8b41cff 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; created_at: string; + /** Краткое описание цели — собирает клиент для списка. */ + target?: string; metadata?: Record; } diff --git a/web/src/components/instance/InstanceAuditPanel.tsx b/web/src/components/instance/InstanceAuditPanel.tsx new file mode 100644 index 0000000..e37a41f --- /dev/null +++ b/web/src/components/instance/InstanceAuditPanel.tsx @@ -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(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): void => { + setState((current) => ({ ...current, ...patch })); + setPage(0); + }; + + return ( +
+
+ + update({ actorId: event.target.value })} + /> + update({ targetId: event.target.value })} + /> + update({ guildId: event.target.value })} + /> + + + + {/* Выгрузка — обычная ссылка: сервер отдаёт CSV с теми же фильтрами. */} + {/* limit/offset в адресе экспорта сервер не читает: важны фильтры. */} + + {t('settings.instance.auditFilters.export')} + +
+ + {audit.isError ? ( + void audit.refetch()} /> + ) : null} + {audit.isPending ? : null} + +

+ {t('settings.instance.auditTotal', { count: total })} +

+ + {entries.length === 0 && !audit.isPending ? ( +

{t('settings.instance.auditEmpty')}

+ ) : ( +
    + {entries.map((entry) => ( +
  • + + {entry.action} + {entry.target_id === undefined ? null : ( + + {' '} + → {entry.target_type ?? '?'}#{entry.target_id} + + )} + {entry.actor_id === undefined ? null : ( + · {entry.actor_id} + )} + {entry.reason === undefined || entry.reason === '' ? null : ( + · {entry.reason} + )} + + + {formatDateTime(entry.created_at, locale)} + +
  • + ))} +
+ )} + +
+ + + {t('settings.instance.pagination.page', { page: page + 1, total: lastPage + 1 })} + + +
+
+ ); +} diff --git a/web/src/components/instance/InstanceGuildsPanel.tsx b/web/src/components/instance/InstanceGuildsPanel.tsx index aaf254b..5e25254 100644 --- a/web/src/components/instance/InstanceGuildsPanel.tsx +++ b/web/src/components/instance/InstanceGuildsPanel.tsx @@ -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

{t('common.loading')}

; - } - if (error !== null && error !== undefined) { - return ; - } - if (guilds.length === 0) { - return

{t('settings.instance.guildsEmpty')}

; - } + const lastPage = total === 0 ? 0 : Math.floor((total - 1) / PAGE_SIZE); return (
{actionError === null ? null : } -
    - {guilds.map((guild) => ( -
  • - {guild.name} - - {guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''} - {t('settings.instance.memberCount', { count: guild.member_count })} - - +
+ +

+ {t('settings.instance.guildsTotal', { count: total })} +

+ + {isPending ? ( +

{t('common.loading')}

+ ) : error !== null && error !== undefined ? ( + + ) : guilds.length === 0 ? ( +

{t('settings.instance.guildsEmpty')}

+ ) : ( +
    + {guilds.map((guild) => ( +
  • - {t('settings.instance.actions.openSettings')} - - - -
  • - ))} -
+ {guild.name} + {guild.owner_name === undefined ? null : ( + @{guild.owner_name} + )} + + {guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''} + {t('settings.instance.memberCount', { count: guild.member_count })} + + + + + + ))} + + )} + +
+ + + {t('settings.instance.pagination.page', { page: page + 1, total: lastPage + 1 })} + + +
{renaming === null ? null : (
Math.max(value, point.count), 0); + return ( +
    + {points.map((point) => ( +
  1. + ))} +
+ ); +} + +/** Плитка с числом и подписью. */ +function StatTile({ label, value, testId }: { label: string; value: string; testId: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +/** + * Вкладка «Статистика» админ-панели инстанса (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 ; + } + if (stats.isError) { + return ( + void stats.refetch()} /> + ); + } + const data = stats.data; + if (data === undefined) { + return null; + } + + return ( +
+
+

{t('settings.instance.stats.usersTitle')}

+
+ + + + + + +
+ +
+ +
+

{t('settings.instance.stats.messagesTitle')}

+
+ + + + +
+ +
+ +
+

{t('settings.instance.stats.storageTitle')}

+
+ + + + + + +
+
+ +
+
+

{t('settings.instance.stats.topGuilds')}

+
    + {data.top_guilds.map((guild) => ( +
  1. + {guild.name} + + {t('settings.instance.stats.members', { count: guild.members })} + +
  2. + ))} +
+
+
+

{t('settings.instance.stats.busiestGuilds')}

+
    + {data.busiest_guilds.map((guild) => ( +
  1. + {guild.name} + + {t('settings.instance.stats.messagesCount', { count: guild.messages })} + +
  2. + ))} +
+
+
+
+ ); +} diff --git a/web/src/components/instance/InstanceUserCard.tsx b/web/src/components/instance/InstanceUserCard.tsx new file mode 100644 index 0000000..1c6c19d --- /dev/null +++ b/web/src/components/instance/InstanceUserCard.tsx @@ -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(null); + const [notice, setNotice] = useState(null); + const [pending, setPending] = useState(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 ; + } + if (card.isError) { + return 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 ( +
+
+

+ {target.display_name} @{target.username} +

+ {target.is_instance_admin ? ( + + {t('settings.instance.adminBadge')} + + ) : null} + {target.banned ? ( + + {t('settings.instance.bannedBadge')} + + ) : null} + {target.id} + +
+ + {actionError === null ? null : } + {notice === null ? null : ( +

+ {notice} +

+ )} + + {pending === null ? null : ( + { + event.preventDefault(); + confirm.mutate(); + }} + > + setStepUpPassword(event.target.value)} + required + /> + {currentUser.data?.totp_enabled === true ? ( + setStepUpCode(event.target.value)} + /> + ) : null} + {confirm.isError ? : null} + + + + )} + +
+
+
{t('settings.instance.card.profile')}
+

+ {t('settings.instance.card.created', { + date: formatDateTime(target.created_at, locale), + })} +

+

+ {t( + data.totp_enabled + ? 'settings.instance.card.twoFactorOn' + : 'settings.instance.card.twoFactorOff', + )} +

+

+ {t('settings.instance.card.passkeys', { count: data.passkeys })} +

+ {target.banned && target.ban_reason !== undefined && target.ban_reason !== '' ? ( +

+ {t('settings.instance.card.banReason', { reason: target.ban_reason })} +

+ ) : null} +
+ +
+
{t('settings.instance.card.guilds')}
+ {data.guilds.length === 0 ? ( +

{t('settings.instance.card.noGuilds')}

+ ) : ( +
    + {data.guilds.map((membership) => ( +
  • + {membership.guild_name} + {membership.nickname === undefined || membership.nickname === '' ? null : ( + ({membership.nickname}) + )} + {membership.roles.map((role) => ( + + {role.name} + + ))} +
  • + ))} +
+ )} +
+
+ +
+
{t('settings.instance.card.sessions')}
+ {data.sessions.length === 0 ? ( +

{t('settings.instance.card.noSessions')}

+ ) : ( +
    + {data.sessions.map((session) => ( +
  • + + {session.user_agent === undefined || session.user_agent === '' + ? t('settings.instance.card.unknownDevice') + : session.user_agent} + + {session.ip === undefined || session.ip === '' ? null : ( + {session.ip} + )} + + {formatDateTime(session.last_seen, locale)} + + +
  • + ))} +
+ )} +
+ +
+
{t('settings.instance.card.securityEvents')}
+ {data.security_events.length === 0 ? ( +

{t('settings.instance.card.noEvents')}

+ ) : ( +
    + {data.security_events.map((event) => ( +
  • + {event.type} + {event.ip === undefined || event.ip === '' ? null : ( + {event.ip} + )} + + {formatDateTime(event.created_at, locale)} + +
  • + ))} +
+ )} +
+ +
+
+ {t('settings.instance.card.audit', { count: data.audit_total })} +
+ {data.audit.length === 0 ? ( +

{t('settings.instance.card.noAudit')}

+ ) : ( +
    + {data.audit.map((entry) => ( +
  • + {entry.action} + {entry.reason === undefined || entry.reason === '' ? null : ( + {entry.reason} + )} + + {formatDateTime(entry.created_at, locale)} + +
  • + ))} +
+ )} +
+ +
+ {/* Свой второй фактор меняют в настройках безопасности, чужой ключ + администратора не сбрасывается (AGENT.md 7.19). */} + + {!data.totp_enabled ? ( + + {t('settings.instance.card.twoFactorOffHint')} + + ) : null} +
+
+ ); +} diff --git a/web/src/components/instance/InstanceUsersPanel.tsx b/web/src/components/instance/InstanceUsersPanel.tsx index f5a907c..9b26c14 100644 --- a/web/src/components/instance/InstanceUsersPanel.tsx +++ b/web/src/components/instance/InstanceUsersPanel.tsx @@ -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({ ) : null} +