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
+56
View File
@@ -2,6 +2,17 @@ import { request } from './client';
import type { AuditEntry, InstanceGuild, InstanceInfo, InstanceUser } from './types';
export interface InstanceSettings {
registration_enabled: boolean;
allow_guild_creation: boolean;
max_guilds_per_user: number;
max_members_per_guild: number;
max_message_length: number;
/** Лимиты медиа в байтах (AGENT.md 7.7): действуют на все сервера. */
max_avatar_size: number;
max_guild_image_size: number;
max_emoji_size: number;
max_sound_size: number;
max_cosmetic_size: number;
[key: string]: unknown;
}
@@ -61,3 +72,48 @@ export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise<Audi
);
return payload.entries;
}
/** `POST /instance/users/{id}/admin` — выдать или снять права администратора. */
export async function setInstanceAdmin(userId: string, makeAdmin: boolean): Promise<void> {
await request(`/instance/users/${encodeURIComponent(userId)}/admin`, {
method: 'POST',
body: { admin: makeAdmin },
});
}
/** `POST /instance/users/{id}/reset-password` — временный пароль (один раз). */
export async function resetUserPassword(
userId: string,
): Promise<{ user_id: string; password: string; sessions_revoked: number }> {
const payload = await request<{ user_id: string; password: string; sessions_revoked: number }>(
`/instance/users/${encodeURIComponent(userId)}/reset-password`,
{ method: 'POST' },
);
return payload;
}
/** `POST /instance/users/{id}/logout` — выйти со всех устройств пользователя. */
export async function logoutUser(userId: string): Promise<void> {
await request(`/instance/users/${encodeURIComponent(userId)}/logout`, { method: 'POST' });
}
/** `DELETE /instance/users/{id}` — мягкое удаление пользователя. */
export async function deleteUser(userId: string): Promise<void> {
await request(`/instance/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
}
/** `PATCH /instance/guilds/{id}` — переименование любого сервера (админ). */
export async function updateInstanceGuild(
guildId: string,
input: { name?: string; description?: string },
): Promise<void> {
await request(`/instance/guilds/${encodeURIComponent(guildId)}`, {
method: 'PATCH',
body: input,
});
}
/** `DELETE /instance/guilds/{id}` — удаление любого сервера (админ). */
export async function deleteInstanceGuild(guildId: string): Promise<void> {
await request(`/instance/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' });
}
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
deleteInstanceGuild,
instanceGuildsQueryKey,
instanceUsersQueryKey,
updateInstanceGuild,
} from '@/api/instance';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import type { InstanceGuild } from '@/api/types';
interface InstanceGuildsPanelProps {
guilds: InstanceGuild[];
isPending: boolean;
error: unknown;
onRetry: () => void;
/** Открыть настройки сервера: админ правит комнаты, роли и ники там. */
onOpenSettings: (guildId: string) => void;
}
/**
* Серверы инстанса (AGENT.md 7.19): администратор переименовывает и удаляет
* любой сервер и открывает его настройки — комнаты, роли и ники правятся
* обычными разделами, права администратора инстанса действуют везде.
*/
export function InstanceGuildsPanel({
guilds,
isPending,
error,
onRetry,
onOpenSettings,
}: InstanceGuildsPanelProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [renaming, setRenaming] = useState<InstanceGuild | null>(null);
const [name, setName] = useState('');
const [actionError, setActionError] = useState<unknown>(null);
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: instanceGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
};
const rename = useMutation({
mutationFn: (input: { id: string; name: string }) =>
updateInstanceGuild(input.id, { name: input.name }),
onSuccess: () => {
setActionError(null);
setRenaming(null);
invalidate();
},
onError: (cause: unknown) => setActionError(cause),
});
const remove = useMutation({
mutationFn: (guildId: string) => deleteInstanceGuild(guildId),
onSuccess: () => {
setActionError(null);
invalidate();
},
onError: (cause: unknown) => setActionError(cause),
});
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>;
}
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)}
>
{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>
{renaming === null ? null : (
<form
className="flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
if (renaming !== null && name.trim() !== '') {
rename.mutate({ id: renaming.id, name: name.trim() });
}
}}
>
<Field
label={t('settings.instance.actions.newName')}
value={name}
maxLength={64}
onChange={(event) => setName(event.target.value)}
/>
<Button type="submit" disabled={rename.isPending}>
{t('common.save')}
</Button>
<Button variant="ghost" onClick={() => setRenaming(null)}>
{t('common.cancel')}
</Button>
</form>
)}
</div>
);
}
@@ -0,0 +1,210 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { updateInstanceSettings, type InstanceSettings } from '@/api/instance';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
interface InstanceLimitsPanelProps {
settings: InstanceSettings;
onSaved: (settings: InstanceSettings) => void;
}
/** Мегабайты в байтах: в интерфейсе лимиты показываем в МБ и КБ. */
function bytesToMb(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) === 0 ? 0 : 1);
}
function mbToBytes(value: string): number | null {
const parsed = Number(value.replace(',', '.'));
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.round(parsed * 1024 * 1024);
}
/**
* Лимиты инстанса (AGENT.md 6.5, 7.7): регистрация, создание серверов,
* размеры сообщений и медиа. Лимиты медиа действуют на все сервера сразу —
* обработчики загрузок берут их из настроек.
*/
export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelProps) {
const { t } = useTranslation();
const [form, setForm] = useState({
registration: settings.registration_enabled,
creation: settings.allow_guild_creation,
guilds: String(settings.max_guilds_per_user),
members: String(settings.max_members_per_guild),
message: String(settings.max_message_length),
avatar: bytesToMb(settings.max_avatar_size),
guildImage: bytesToMb(settings.max_guild_image_size),
emoji: bytesToMb(settings.max_emoji_size),
sound: bytesToMb(settings.max_sound_size),
cosmetic: bytesToMb(settings.max_cosmetic_size),
});
const [error, setError] = useState<string | null>(null);
const save = useMutation({
mutationFn: (patch: Partial<InstanceSettings>) => updateInstanceSettings(patch),
onSuccess: (updated) => {
setError(null);
// Форму приводим к ответу сервера: он источник правды по лимитам.
setForm({
registration: updated.registration_enabled,
creation: updated.allow_guild_creation,
guilds: String(updated.max_guilds_per_user),
members: String(updated.max_members_per_guild),
message: String(updated.max_message_length),
avatar: bytesToMb(updated.max_avatar_size),
guildImage: bytesToMb(updated.max_guild_image_size),
emoji: bytesToMb(updated.max_emoji_size),
sound: bytesToMb(updated.max_sound_size),
cosmetic: bytesToMb(updated.max_cosmetic_size),
});
onSaved(updated);
},
});
const onSubmit = (): void => {
const sizes = {
max_avatar_size: mbToBytes(form.avatar),
max_guild_image_size: mbToBytes(form.guildImage),
max_emoji_size: mbToBytes(form.emoji),
max_sound_size: mbToBytes(form.sound),
max_cosmetic_size: mbToBytes(form.cosmetic),
};
if (Object.values(sizes).some((value) => value === null)) {
setError(t('settings.instance.limits.invalidSize'));
return;
}
const guilds = Number(form.guilds);
const members = Number(form.members);
const message = Number(form.message);
if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) {
setError(t('settings.instance.limits.invalidNumber'));
return;
}
setError(null);
// Размеры проверены выше: null уже отсеян, здесь только числа.
save.mutate({
registration_enabled: form.registration,
allow_guild_creation: form.creation,
max_guilds_per_user: guilds,
max_members_per_guild: members,
max_message_length: message,
max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size,
max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size,
max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size,
max_sound_size: sizes.max_sound_size ?? settings.max_sound_size,
max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size,
});
};
return (
<form
className="mt-3 flex flex-col gap-4"
data-testid="instance-limits"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.registration}
data-testid="instance-registration"
onChange={(event) => setForm({ ...form, registration: event.target.checked })}
/>
{t('settings.instance.limits.registration')}
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.creation}
data-testid="instance-creation"
onChange={(event) => setForm({ ...form, creation: event.target.checked })}
/>
{t('settings.instance.limits.creation')}
</label>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Field
label={t('settings.instance.limits.guilds')}
type="number"
value={form.guilds}
onChange={(event) => setForm({ ...form, guilds: event.target.value })}
/>
<Field
label={t('settings.instance.limits.members')}
type="number"
value={form.members}
onChange={(event) => setForm({ ...form, members: event.target.value })}
/>
<Field
label={t('settings.instance.limits.message')}
type="number"
value={form.message}
onChange={(event) => setForm({ ...form, message: event.target.value })}
/>
</div>
{/* Лимиты медиа: в мегабайтах, сервер получает байты. */}
<div className="grid gap-3 sm:grid-cols-3">
<Field
label={t('settings.instance.limits.avatar')}
type="number"
step="0.1"
value={form.avatar}
onChange={(event) => setForm({ ...form, avatar: event.target.value })}
/>
<Field
label={t('settings.instance.limits.guildImage')}
type="number"
step="0.1"
value={form.guildImage}
onChange={(event) => setForm({ ...form, guildImage: event.target.value })}
/>
<Field
label={t('settings.instance.limits.cosmetic')}
type="number"
step="0.1"
value={form.cosmetic}
onChange={(event) => setForm({ ...form, cosmetic: event.target.value })}
/>
<Field
label={t('settings.instance.limits.emoji')}
type="number"
step="0.1"
value={form.emoji}
onChange={(event) => setForm({ ...form, emoji: event.target.value })}
/>
<Field
label={t('settings.instance.limits.sound')}
type="number"
step="0.1"
value={form.sound}
onChange={(event) => setForm({ ...form, sound: event.target.value })}
/>
</div>
{error === null ? null : <ErrorNotice error={new Error(error)} />}
{save.isError ? <ErrorNotice error={save.error} /> : null}
{save.isSuccess ? (
<p className="text-sm text-success" role="status">
{t('settings.instance.limits.saved')}
</p>
) : null}
<div className="flex justify-end">
<Button type="submit" data-testid="instance-limits-save" disabled={save.isPending}>
{t(save.isPending ? 'common.saving' : 'common.save')}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,256 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { deleteUser, instanceUsersQueryKey, logoutUser, resetUserPassword } from '@/api/instance';
import { Button } from '@/components/ui/primitives';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { stepUp } from '@/api/auth';
import { useCurrentUser } from '@/lib/hooks';
import { useEffect } from 'react';
interface InstanceUsersPanelProps {
/** Свой id: себя удалять нельзя (сервер тоже это проверяет). */
currentUserId: string | undefined;
/** Признак, что список загружен: панель показывает действия. */
users: { id: string; username: string; display_name: string; is_instance_admin: boolean }[];
isPending: boolean;
error: unknown;
onRetry: () => void;
/** Выдать или снять права администратора инстанса. */
onToggleAdmin: (userId: string, makeAdmin: boolean) => void;
adminPending: boolean;
/** Ошибка смены прав: сервер требует step-up или отказывает. */
adminError?: unknown;
}
/**
* Действия администратора инстанса над пользователем (AGENT.md 7.19):
* временный пароль, выход со всех устройств и мягкое удаление.
*/
export function InstanceUsersPanel({
currentUserId,
users,
isPending,
error,
onRetry,
onToggleAdmin,
adminPending,
adminError,
}: InstanceUsersPanelProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const [password, setPassword] = useState<{ userId: string; value: string } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const [notice, setNotice] = useState<string | null>(null);
// Смена администраторов требует подтверждения личности (AGENT.md 7.1).
const [stepUpFor, setStepUpFor] = useState<{ userId: string; makeAdmin: boolean } | null>(null);
const [stepUpPassword, setStepUpPassword] = useState('');
const [stepUpCode, setStepUpCode] = useState('');
const confirm = useMutation({
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
onSuccess: () => {
const target = stepUpFor;
setStepUpPassword('');
setStepUpCode('');
if (target !== null) {
onToggleAdmin(target.userId, target.makeAdmin);
}
},
});
// Пароль показывается один раз: через минуту убираем из интерфейса.
useEffect(() => {
if (password === null) {
return;
}
const timer = window.setTimeout(() => setPassword(null), 60_000);
return () => window.clearTimeout(timer);
}, [password]);
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
};
const reset = useMutation({
mutationFn: (userId: string) => resetUserPassword(userId),
onSuccess: (result) => {
setActionError(null);
setPassword({ userId: result.user_id, value: result.password });
},
onError: (cause: unknown) => setActionError(cause),
});
const logout = useMutation({
mutationFn: (userId: string) => logoutUser(userId),
onSuccess: () => {
setActionError(null);
setNotice(t('settings.instance.actions.loggedOut'));
invalidate();
},
onError: (cause: unknown) => setActionError(cause),
});
const remove = useMutation({
mutationFn: (userId: string) => deleteUser(userId),
onSuccess: () => {
setActionError(null);
invalidate();
},
onError: (cause: unknown) => setActionError(cause),
});
const pending = reset.isPending || logout.isPending || remove.isPending || adminPending;
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 (users.length === 0) {
return <p className="mt-2 text-sm text-fg-muted">{t('settings.instance.usersEmpty')}</p>;
}
return (
<div className="mt-3 flex flex-col gap-2" data-testid="instance-users-actions">
{actionError === null ? null : <ErrorNotice error={actionError} />}
{adminError === null || adminError === undefined ? null : <ErrorNotice error={adminError} />}
{notice === null ? null : (
<p className="text-sm text-success" role="status">
{notice}
</p>
)}
{stepUpFor === 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-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={() => setStepUpFor(null)}>
{t('common.cancel')}
</Button>
</form>
)}
{password === null ? null : (
<p className="rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-2 text-sm">
<span className="text-fg-muted">{t('settings.instance.actions.passwordOnce')}: </span>
<code className="font-mono" data-testid="instance-temp-password">
{password.value}
</code>
</p>
)}
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-users">
{users.map((user) => {
const self = user.id === currentUserId;
return (
<li
key={user.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">
{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')}
>
{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>
) : null}
<Button
variant="ghost"
data-testid={`instance-user-admin-${user.id}`}
disabled={pending}
onClick={() => {
setActionError(null);
onToggleAdmin(user.id, !user.is_instance_admin);
// Подтверждение покажем, если сервер его потребует.
setStepUpFor({ userId: user.id, makeAdmin: !user.is_instance_admin });
}}
>
{t(
user.is_instance_admin
? 'settings.instance.actions.removeAdmin'
: 'settings.instance.actions.makeAdmin',
)}
</Button>
<Button
variant="ghost"
data-testid={`instance-user-reset-${user.id}`}
disabled={pending}
onClick={() => reset.mutate(user.id)}
>
{t('settings.instance.actions.resetPassword')}
</Button>
<Button
variant="ghost"
data-testid={`instance-user-logout-${user.id}`}
disabled={pending}
onClick={() => logout.mutate(user.id)}
>
{t('settings.instance.actions.logout')}
</Button>
{/* Себя удалять нельзя: сервер отвечает 422. */}
<Button
variant="ghost"
className="border border-danger/50 text-danger"
data-testid={`instance-user-delete-${user.id}`}
disabled={pending || self}
onClick={() => {
if (
window.confirm(
t('settings.instance.actions.deleteConfirm', { name: user.username }),
)
) {
remove.mutate(user.id);
}
}}
>
{t('settings.instance.actions.delete')}
</Button>
</li>
);
})}
</ul>
</div>
);
}
+41
View File
@@ -832,6 +832,47 @@
"gateway": "Gateway",
"metrics": "Metrics"
}
},
"tabs": {
"label": "Admin panel sections",
"overview": "Overview",
"limits": "Limits",
"users": "Users ({{count}})",
"guilds": "Servers ({{count}})",
"audit": "Audit"
},
"limits": {
"title": "Limits and registration",
"description": "Limits apply to the whole instance: media sizes are checked on every upload.",
"registration": "Registration is open",
"creation": "Users may create servers",
"guilds": "Servers per user",
"members": "Members per server",
"message": "Message length",
"avatar": "Avatar, MB",
"guildImage": "Server artwork, MB",
"cosmetic": "Frames and icons, MB",
"emoji": "Emoji, MB",
"sound": "Sounds, MB",
"invalidSize": "Sizes must be positive numbers in megabytes",
"invalidNumber": "Limits must be numbers",
"saved": "Limits saved."
},
"usersTitle": "Instance users",
"guildsTitle": "Instance servers",
"actions": {
"makeAdmin": "Make admin",
"removeAdmin": "Revoke admin",
"resetPassword": "Reset password",
"logout": "Log out everywhere",
"delete": "Delete",
"deleteConfirm": "Delete user {{name}}? They leave all servers, message history stays.",
"loggedOut": "User sessions revoked.",
"passwordOnce": "Temporary password (shown once)",
"openSettings": "Settings",
"rename": "Rename",
"newName": "New server name",
"deleteGuildConfirm": "Delete server “{{name}}” with all its channels?"
}
},
"servers": {
+41
View File
@@ -832,6 +832,47 @@
"gateway": "Gateway",
"metrics": "Метрики"
}
},
"tabs": {
"label": "Разделы админ-панели",
"overview": "Обзор",
"limits": "Лимиты",
"users": "Пользователи ({{count}})",
"guilds": "Серверы ({{count}})",
"audit": "Аудит"
},
"limits": {
"title": "Лимиты и регистрация",
"description": "Лимиты действуют на весь инстанс: размеры медиа проверяются при каждой загрузке.",
"registration": "Регистрация открыта",
"creation": "Пользователи могут создавать серверы",
"guilds": "Серверов на пользователя",
"members": "Участников на сервер",
"message": "Длина сообщения",
"avatar": "Аватар, МБ",
"guildImage": "Оформление сервера, МБ",
"cosmetic": "Рамки и иконки, МБ",
"emoji": "Эмодзи, МБ",
"sound": "Звуки, МБ",
"invalidSize": "Размеры указываются положительными числами в мегабайтах",
"invalidNumber": "Лимиты указываются числами",
"saved": "Лимиты сохранены."
},
"usersTitle": "Пользователи инстанса",
"guildsTitle": "Серверы инстанса",
"actions": {
"makeAdmin": "Сделать админом",
"removeAdmin": "Снять админа",
"resetPassword": "Сбросить пароль",
"logout": "Выйти везде",
"delete": "Удалить",
"deleteConfirm": "Удалить пользователя {{name}}? Он исчезнет из серверов, история сообщений останется.",
"loggedOut": "Сессии пользователя отозваны.",
"passwordOnce": "Временный пароль (показывается один раз)",
"openSettings": "Настройки",
"rename": "Переименовать",
"newName": "Новое имя сервера",
"deleteGuildConfirm": "Удалить сервер «{{name}}» вместе со всеми комнатами?"
}
},
"servers": {
+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 '—';
}
}