feat(instance): админ-панель с лимитами медиа и действиями над пользователями
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user