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
+128 -3
View File
@@ -541,6 +541,8 @@ describe('настройки: аватар', () => {
renderApp('/app/empty');
const dialog = await openSettings();
await goToSection(dialog, 'Инстанс');
// Админ-панель разбита на вкладки: пользователи живут в своей.
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
@@ -722,9 +724,132 @@ describe('настройки: безопасность', () => {
renderApp('/settings/account/instance');
// Серверы инстанса.
await userEvent.click(await screen.findByTestId('instance-tab-guilds'));
expect(await screen.findByTestId('instance-guilds')).toHaveTextContent('Main');
expect(screen.getByTestId('instance-users')).toHaveTextContent('Alice');
expect(screen.getByTestId('instance-audit')).toHaveTextContent('guild.create');
expect(screen.getByText('motd')).toBeVisible();
// Пользователи инстанса.
await userEvent.click(screen.getByTestId('instance-tab-users'));
expect(await screen.findByTestId('instance-users')).toHaveTextContent('Alice');
// Журнал действий администраторов.
await userEvent.click(screen.getByTestId('instance-tab-audit'));
expect(await screen.findByTestId('instance-audit')).toHaveTextContent('guild.create');
// Лимиты: панель показывает значения из настроек.
await userEvent.click(screen.getByTestId('instance-tab-limits'));
expect(await screen.findByTestId('instance-limits')).toBeVisible();
});
});
describe('админ-панель: действия', () => {
it('сбрасывает пароль, выкидывает со всех устройств и удаляет пользователя', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const admin = makeUser({ is_instance_admin: true });
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
{
match: '/api/v1/instance/users/u-7/reset-password',
method: 'POST',
response: () => json({ user_id: 'u-7', password: 'Temp-Pass-123456', sessions_revoked: 2 }),
},
{
match: '/api/v1/instance/users/u-7/logout',
method: 'POST',
response: () => json({ ok: true }),
},
{
match: '/api/v1/instance/users/u-7',
method: 'DELETE',
response: () => json({ ok: true }),
},
{
match: '/api/v1/instance/users',
response: () =>
json({
users: [
{
id: 'u-7',
username: 'bob',
display_name: 'Bob',
is_instance_admin: false,
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
]);
renderApp('/settings/account/instance');
await userEvent.click(await screen.findByTestId('instance-tab-users'));
const panel = await screen.findByTestId('instance-users-actions');
// Временный пароль показывается один раз.
await userEvent.click(within(panel).getByTestId('instance-user-reset-u-7'));
expect(await screen.findByTestId('instance-temp-password')).toHaveTextContent(
'Temp-Pass-123456',
);
// Выход со всех устройств.
await userEvent.click(within(panel).getByTestId('instance-user-logout-u-7'));
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.method === 'POST' && request.url.includes('/users/u-7/logout'),
),
).toBe(true);
});
// Мягкое удаление.
await userEvent.click(within(panel).getByTestId('instance-user-delete-u-7'));
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.method === 'DELETE' && request.url.endsWith('/users/u-7'),
),
).toBe(true);
});
});
it('переименовывает сервер инстанса', async () => {
const admin = makeUser({ is_instance_admin: true });
const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user: admin }) },
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
{
match: '/api/v1/instance/guilds/g-1',
method: 'PATCH',
response: () => json({ ok: true }),
},
{
match: '/api/v1/instance/guilds',
response: () =>
json({
guilds: [{ id: 'g-1', name: 'Старое', member_count: 3, owner_id: 'u', is_main: false }],
}),
},
{ match: '/api/v1/instance/users', response: () => json({ users: [] }) },
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
]);
renderApp('/settings/account/instance');
await userEvent.click(await screen.findByTestId('instance-tab-guilds'));
const panel = await screen.findByTestId('instance-guilds-actions');
await userEvent.click(within(panel).getByTestId('instance-guild-rename-g-1'));
const nameInput = screen.getByLabelText('Новое имя сервера');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Новое');
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
const request = recordedRequests(fetchMock).find(
(entry) => entry.method === 'PATCH' && entry.url.includes('/instance/guilds/g-1'),
);
expect(request?.body).toMatchObject({ name: 'Новое' });
});
});
});