ef871bcd96
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера)
239 lines
9.0 KiB
TypeScript
239 lines
9.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import {
|
|
fetchAudit,
|
|
fetchInstanceGuilds,
|
|
fetchInstanceSettings,
|
|
fetchInstanceUsers,
|
|
instanceAuditQueryKey,
|
|
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';
|
|
import { useCurrentUser } from '@/lib/hooks';
|
|
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,
|
|
queryFn: ({ signal }) => fetchInstanceSettings(signal),
|
|
enabled: isAdmin,
|
|
retry: 0,
|
|
});
|
|
const guilds = useQuery({
|
|
queryKey: instanceGuildsQueryKey,
|
|
queryFn: ({ signal }) => fetchInstanceGuilds(signal),
|
|
enabled: isAdmin,
|
|
retry: 0,
|
|
});
|
|
const users = useQuery({
|
|
queryKey: instanceUsersQueryKey,
|
|
queryFn: ({ signal }) => fetchInstanceUsers(signal),
|
|
enabled: isAdmin,
|
|
retry: 0,
|
|
});
|
|
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 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>
|
|
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
|
|
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.notAdmin')}</p>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<header>
|
|
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
|
|
<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')}
|
|
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>
|
|
|
|
{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}
|
|
/>
|
|
</Card>
|
|
) : null}
|
|
|
|
{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 });
|
|
}}
|
|
/>
|
|
</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>
|
|
)}
|
|
</Card>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|