feat(instance): админ-панель с лимитами медиа и действиями над пользователями

- лимиты медиа живут в настройках инстанса (миграция 00016): аватары,
  оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел
  оттуда, значения по умолчанию — из AGENT.md 7.7
- действия администратора: временный пароль (показывается один раз, сессии
  отзываются), выход со всех устройств, мягкое удаление пользователя
  (сообщения и аудит остаются), переименование и удаление любого сервера
- админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи,
  серверы, аудит; смена администраторов подтверждается личностью (step-up)
- тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest
  (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
2026-09-21 22:37:47 +03:00
parent dd4432452c
commit ef871bcd96
20 changed files with 1617 additions and 204 deletions
+153 -147
View File
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -10,9 +11,16 @@ import {
instanceGuildsQueryKey,
instanceSettingsQueryKey,
instanceUsersQueryKey,
setInstanceAdmin,
type InstanceSettings,
} from '@/api/instance';
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 { 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';
@@ -21,12 +29,20 @@ import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
const AUDIT_LIMIT = 50;
/** Разделы админ-панели (AGENT.md 7.19). */
const tabs = ['overview', '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 currentUser = useCurrentUser();
const isAdmin = currentUser.data?.is_instance_admin === true;
const queryClient = useQueryClient();
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
const openSettings = useUiStore((state) => state.openSettings);
const [tab, setTab] = useState<InstanceTab>('overview');
const settings = useQuery({
queryKey: instanceSettingsQueryKey,
@@ -55,6 +71,15 @@ export default function InstanceSettingsPage() {
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
const metrics = useInstanceMetrics(isAdmin);
const admin = useMutation({
mutationFn: (input: { userId: string; makeAdmin: boolean }) =>
setInstanceAdmin(input.userId, input.makeAdmin),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
},
});
if (!isAdmin) {
return (
<Card>
@@ -64,8 +89,13 @@ export default function InstanceSettingsPage() {
);
}
const settingsEntries =
settings.data === undefined ? [] : Object.entries(settings.data).slice(0, 30);
const tabLabels: Record<InstanceTab, string> = {
overview: t('settings.instance.tabs.overview'),
limits: t('settings.instance.tabs.limits'),
users: t('settings.instance.tabs.users', { count: users.data?.length ?? 0 }),
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.length ?? 0 }),
audit: t('settings.instance.tabs.audit'),
};
return (
<div className="flex flex-col gap-5">
@@ -74,159 +104,135 @@ export default function InstanceSettingsPage() {
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
</header>
<InstanceDashboard state={metrics} />
{/* Вкладки админ-панели: обзор и здоровье, лимиты, пользователи, серверы, аудит. */}
<div
role="tablist"
aria-label={t('settings.instance.tabs.label')}
className="flex flex-wrap gap-1 border-b border-border/40"
data-testid="instance-tabs"
>
{tabs.map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={tab === item}
data-testid={`instance-tab-${item}`}
onClick={() => setTab(item)}
className={`-mb-px rounded-t-[var(--radius-sm)] border-b-2 px-3 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
tab === item
? 'border-accent text-fg'
: 'border-transparent text-fg-muted hover:text-fg'
}`}
>
{tabLabels[item]}
</button>
))}
</div>
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.settings')}</h3>
{settings.isPending ? <LoadingNotice /> : null}
{settings.isError ? (
<ErrorNotice
className="mt-3"
error={settings.error}
onRetry={() => void settings.refetch()}
{tab === 'overview' ? (
<>
<InstanceDashboard state={metrics} />
<InstanceHealth state={metrics} />
</>
) : null}
{tab === 'limits' ? (
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.limits.title')}</h3>
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.limits.description')}</p>
{settings.isPending ? <LoadingNotice /> : null}
{settings.isError ? (
<ErrorNotice
className="mt-3"
error={settings.error}
onRetry={() => void settings.refetch()}
/>
) : null}
{settings.data === undefined ? null : (
<InstanceLimitsPanel
settings={settings.data}
onSaved={(updated: InstanceSettings) => {
void queryClient.setQueryData(instanceSettingsQueryKey, updated);
}}
/>
)}
</Card>
) : null}
{tab === 'users' ? (
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.usersTitle')}</h3>
{/* ID пользователей — единственное место, где он виден: раздел открыт
только администратору инстанса. */}
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
<InstanceUsersPanel
currentUserId={currentUser.data?.id}
users={users.data ?? []}
isPending={users.isPending}
error={users.error}
onRetry={() => void users.refetch()}
onToggleAdmin={(userId, makeAdmin) => admin.mutate({ userId, makeAdmin })}
adminPending={admin.isPending}
adminError={admin.error}
/>
) : null}
{!settings.isPending && !settings.isError && settingsEntries.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.settingsEmpty')}</p>
) : null}
{settingsEntries.length === 0 ? null : (
<dl className="mt-3 text-sm">
{settingsEntries.map(([key, value]) => (
<div
key={key}
className="flex justify-between gap-4 border-b border-border/40 py-2 last:border-b-0"
>
<dt className="text-fg-muted">{key}</dt>
<dd className="max-w-[60%] truncate text-right font-medium" title={format(value)}>
{format(value)}
</dd>
</div>
))}
</dl>
)}
</Card>
</Card>
) : null}
<Card>
<h3 className="text-base font-semibold">
{t('settings.instance.guilds', { count: guilds.data?.length ?? 0 })}
</h3>
{guilds.isPending ? <LoadingNotice /> : null}
{guilds.isError ? (
<ErrorNotice
className="mt-3"
{tab === 'guilds' ? (
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.guildsTitle')}</h3>
<InstanceGuildsPanel
guilds={guilds.data ?? []}
isPending={guilds.isPending}
error={guilds.error}
onRetry={() => void guilds.refetch()}
onOpenSettings={(guildId) => {
// Настройки сервера открываются обычным разделом: у администратора
// инстанса там все права (AGENTS.md 7.19).
selectSettingsGuild(guildId);
openSettings({ section: 'server', guildId });
}}
/>
) : null}
{guilds.data !== undefined && guilds.data.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>
) : null}
{guilds.data === undefined || guilds.data.length === 0 ? null : (
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-guilds">
{guilds.data.map((guild) => (
<li
key={guild.id}
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
>
<span className="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>
</li>
))}
</ul>
)}
</Card>
</Card>
) : null}
<Card>
<h3 className="text-base font-semibold">
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
</h3>
{/* ID пользователей — единственное место, где он виден: раздел открыт
только администратору инстанса. */}
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
{users.isPending ? <LoadingNotice /> : null}
{users.isError ? (
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
) : null}
{users.data !== undefined && users.data.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.usersEmpty')}</p>
) : null}
{users.data === undefined || users.data.length === 0 ? null : (
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-users">
{users.data.map((user) => (
<li
key={user.id}
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
>
<span className="min-w-0 flex-1 truncate">
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
</span>
<span
className="shrink-0 font-mono text-xs text-fg-muted"
data-testid={`instance-user-id-${user.id}`}
title={t('settings.instance.userId')}
{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"
>
{user.id}
</span>
{user.is_instance_admin ? (
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
{t('settings.instance.adminBadge')}
<span className="truncate">
{entry.action}
{entry.target === undefined ? '' : ` → ${entry.target}`}
</span>
) : null}
</li>
))}
</ul>
)}
</Card>
<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>
)}
</Card>
<InstanceHealth state={metrics} />
<span className="shrink-0 text-xs text-fg-muted">
{formatDateTime(entry.created_at, locale)}
</span>
</li>
))}
</ul>
)}
</Card>
) : null}
</div>
);
}
function format(value: unknown): string {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return '—';
}
}