feat(web): статистика, карточка пользователя и фильтры аудита в админ-панели (Фаза 7)

Новые разделы панели инстанса (AGENT.md 3.2, 7.18):

- вкладка «Статистика»: рост пользователей и сообщений по дням, размеры
  данных, топы серверов;
- карточка пользователя из списка: устройства с отзывом, серверы и роли,
  события безопасности, аудит по пользователю; отзыв устройства и сброс
  второго фактора подтверждаются step-up;
- вкладка «Аудит»: фильтры по действию, актору, цели, серверу и датам,
  пагинация и ссылка на выгрузку CSV;
- вкладка «Серверы»: поиск по названию, фильтр по владельцу и главному
  серверу, пагинация;
- ключи i18n для новых разделов (ru/en, паритет), тесты панели.
This commit is contained in:
2026-09-26 16:44:53 +03:00
parent 3dc200c196
commit 5320618d31
12 changed files with 1791 additions and 135 deletions
+5 -1
View File
@@ -152,7 +152,11 @@ describe('api-модули Фазы 1', () => {
]);
await expect(fetchInstance()).resolves.toEqual({ name: 'glchat' });
await expect(fetchAudit(50)).resolves.toEqual([{ id: 'a-1' }]);
// Журнал отдаётся вместе с общим числом записей под фильтр (AGENT.md 7.18).
await expect(fetchAudit({ limit: 50 })).resolves.toEqual({
entries: [{ id: 'a-1' }],
total: 1,
});
expect(recordedRequests(fetchMock).map((request) => request.url)).toContain(
'/api/v1/instance/audit?limit=50',
);
+354
View File
@@ -0,0 +1,354 @@
import { describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { apiError, installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers';
/** Ответ GET /instance/stats в формате ручки (AGENT.md 7.18). */
function statsPayload() {
return {
stats: {
users: {
total: 42,
admins: 2,
banned: 1,
new_7_days: 5,
new_30_days: 17,
active_24h: 9,
daily: [
{ date: '2026-09-25', count: 2 },
{ date: '2026-09-26', count: 3 },
],
},
messages: {
total: 1500,
today: 40,
week: 300,
month: 900,
daily: [
{ date: '2026-09-25', count: 10 },
{ date: '2026-09-26', count: 40 },
],
},
files: { count: 12, bytes: 3 * 1024 * 1024 },
guilds: 4,
invites: 3,
bans: 1,
database_bytes: 2 * 1024 * 1024,
storage_bytes: 5 * 1024 * 1024,
top_guilds: [{ id: 'g-1', name: 'Альфа', members: 12, messages: 400 }],
busiest_guilds: [{ id: 'g-2', name: 'Бета', members: 5, messages: 900 }],
},
};
}
/** Карточка пользователя GET /instance/users/{id}. */
function userCardPayload() {
return {
user: {
id: 'u-1',
username: 'card_target',
display_name: 'Цель',
is_instance_admin: false,
badges: [],
created_at: '2026-09-20T10:00:00Z',
banned: false,
},
totp_enabled: true,
passkeys: 1,
sessions: [
{
id: 's-1',
user_agent: 'Тестовый браузер',
ip: '127.0.0.1',
created_at: '2026-09-26T09:00:00Z',
last_seen: '2026-09-26T10:00:00Z',
expires_at: '2026-10-26T09:00:00Z',
stepped_up: false,
},
],
guilds: [
{
guild_id: 'g-1',
guild_name: 'Альфа',
nickname: 'гость',
joined_at: '2026-09-21T10:00:00Z',
roles: [{ id: 'r-1', name: 'Модератор', color: 16711680 }],
},
],
security_events: [
{ id: 'e-1', type: 'login', ip: '127.0.0.1', created_at: '2026-09-26T09:00:00Z' },
],
audit: [
{
id: 'a-1',
action: 'instance.user_ban',
target_type: 'user',
target_id: 'u-1',
created_at: '2026-09-25T09:00:00Z',
},
],
audit_total: 1,
};
}
function appRoutes(extra: FetchRoute[]): FetchRoute[] {
return [
...extra,
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{
match: '/api/v1/users/@me',
response: () => json({ user: makeUser({ is_instance_admin: true }) }),
},
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
];
}
/** openInstance открывает настройки и переходит на вкладку «Инстанс». */
async function openInstance(): Promise<HTMLElement> {
const gear = await screen.findByLabelText('Настройки пользователя');
await userEvent.click(gear);
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
await userEvent.click(await within(dialog).findByRole('button', { name: 'Инстанс' }));
return dialog;
}
describe('расширенная админ-панель инстанса', () => {
it('показывает статистику роста, хранилища и топы серверов', async () => {
installFetch(
appRoutes([
{ match: '/api/v1/instance/stats', response: () => json(statsPayload()) },
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
]),
);
renderApp('/app/empty');
const dialog = await openInstance();
await userEvent.click(await within(dialog).findByTestId('instance-tab-stats'));
expect(await within(dialog).findByTestId('instance-stats-users')).toHaveTextContent('42');
expect(within(dialog).getByTestId('instance-stats-users-7')).toHaveTextContent('5');
expect(within(dialog).getByTestId('instance-stats-messages')).toHaveTextContent('1 500');
expect(within(dialog).getByTestId('instance-stats-storage')).toHaveTextContent('5 MB');
expect(within(dialog).getByTestId('instance-stats-guilds')).toHaveTextContent('4');
// Полоски роста: по одной на день из ответа.
const daily = within(dialog).getByTestId('instance-stats-messages-daily');
expect(within(daily).getAllByRole('listitem')).toHaveLength(2);
expect(within(dialog).getByTestId('instance-stats-top-guilds')).toHaveTextContent('Альфа');
expect(within(dialog).getByTestId('instance-stats-busiest-guilds')).toHaveTextContent('Бета');
});
it('фильтрует аудит, листает страницы и отдаёт ссылку на экспорт', async () => {
const requests: string[] = [];
installFetch(
appRoutes([
{
match: '/api/v1/instance/audit/actions',
response: () => json({ actions: ['instance.user_ban', 'instance.settings_update'] }),
},
{
match: '/api/v1/instance/audit',
response: ({ url }) => {
requests.push(url);
return json({
entries: [
{
id: 'a-1',
action: 'instance.user_ban',
actor_id: 'u-9',
target_type: 'user',
target_id: 'u-1',
reason: 'спам',
created_at: '2026-09-25T09:00:00Z',
},
],
total: 120,
});
},
},
]),
);
renderApp('/app/empty');
const dialog = await openInstance();
await userEvent.click(await within(dialog).findByTestId('instance-tab-audit'));
const list = await within(dialog).findByTestId('instance-audit');
expect(list).toHaveTextContent('instance.user_ban');
expect(list).toHaveTextContent('спам');
expect(within(dialog).getByTestId('instance-audit-total')).toHaveTextContent('120');
// Фильтр по действию уходит в запрос.
await userEvent.selectOptions(
within(dialog).getByTestId('instance-audit-action'),
'instance.user_ban',
);
await waitFor(() => {
expect(requests.some((url) => url.includes('action=instance.user_ban'))).toBe(true);
});
// Ссылка на выгрузку несёт тот же фильтр.
expect(within(dialog).getByTestId('instance-audit-export')).toHaveAttribute(
'href',
expect.stringContaining('action=instance.user_ban'),
);
// Пагинация: 120 записей по 50 — три страницы, «вперёд» уходит на offset=50.
await userEvent.click(within(dialog).getByTestId('instance-audit-next'));
await waitFor(() => {
expect(requests.some((url) => url.includes('offset=50'))).toBe(true);
});
expect(within(dialog).getByTestId('instance-audit-page')).toHaveTextContent('Страница 2 из 3');
});
it('ищет серверы по названию и листает список', async () => {
const requests: string[] = [];
installFetch(
appRoutes([
{
match: '/api/v1/instance/guilds',
response: ({ url }) => {
requests.push(url);
return json({
guilds: [
{
id: 'g-1',
name: 'Альфа',
owner_id: 'u-1',
owner_name: 'owner',
member_count: 3,
is_main: false,
},
],
total: 60,
});
},
},
{ match: '/api/v1/instance/users', response: () => json({ users: [], total: 0 }) },
]),
);
renderApp('/app/empty');
const dialog = await openInstance();
await userEvent.click(await within(dialog).findByTestId('instance-tab-guilds'));
expect(await within(dialog).findByTestId('instance-guild-g-1')).toHaveTextContent('Альфа');
expect(within(dialog).getByTestId('instance-guilds-total')).toHaveTextContent('60');
await userEvent.type(within(dialog).getByLabelText('Поиск'), 'альф');
await waitFor(() => {
expect(requests.some((url) => url.includes('q='))).toBe(true);
});
await userEvent.click(within(dialog).getByTestId('instance-guilds-next'));
await waitFor(() => {
expect(requests.some((url) => url.includes('offset=50'))).toBe(true);
});
expect(within(dialog).getByTestId('instance-guilds-page')).toHaveTextContent('Страница 2 из 2');
});
it('открывает карточку пользователя, отзывает устройство и сбрасывает 2FA', async () => {
const calls: { url: string; method: string; body: unknown }[] = [];
installFetch(
appRoutes([
{
match: '/api/v1/instance/users',
response: () => json({ users: [makeInstanceUser()], total: 1 }),
},
{
match: '/api/v1/auth/step-up',
method: 'POST',
response: () => {
calls.push({ url: 'step-up', method: 'POST', body: null });
return json({ ok: true });
},
},
{
match: '/api/v1/instance/users/u-1/sessions/s-1',
method: 'DELETE',
response: () => {
calls.push({ url: 'revoke', method: 'DELETE', body: null });
// Первый вызов сервер отклоняет: нужна свежая проверка личности
// (AGENT.md 7.1), после step-up действие проходит.
return calls.filter((call) => call.url === 'revoke').length === 1
? apiError('auth.step_up_required', 403)
: json({ ok: true });
},
},
{
match: '/api/v1/instance/users/u-1/reset-2fa',
method: 'POST',
response: () => {
calls.push({ url: 'reset-2fa', method: 'POST', body: null });
return json({ user_id: 'u-1', sessions_revoked: 2 });
},
},
{
match: '/api/v1/instance/users/u-1',
response: () => json(userCardPayload()),
},
]),
);
renderApp('/app/empty');
const dialog = await openInstance();
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
await userEvent.click(await within(dialog).findByTestId('instance-user-card-u-1'));
const card = await within(dialog).findByTestId('instance-user-card');
expect(within(card).getByTestId('instance-user-card-guilds')).toHaveTextContent('Модератор');
expect(within(card).getByTestId('instance-user-card-totp')).toHaveTextContent(
'Второй фактор включён',
);
expect(within(card).getByTestId('instance-user-card-events')).toHaveTextContent('login');
expect(within(card).getByTestId('instance-user-card-audit')).toHaveTextContent(
'instance.user_ban',
);
// Отзыв устройства — чувствительное действие: сервер требует step-up,
// панель показывает форму подтверждения (AGENT.md 7.1).
await userEvent.click(within(card).getByTestId('instance-user-card-revoke-s-1'));
const stepUpForm = await within(card).findByTestId('instance-user-card-step-up');
expect(stepUpForm).toBeVisible();
await userEvent.type(within(stepUpForm).getByLabelText('Ваш пароль'), 'correct-horse-battery');
await userEvent.click(within(stepUpForm).getByRole('button', { name: 'Подтвердить' }));
await waitFor(() => {
expect(calls.filter((call) => call.url === 'revoke')).toHaveLength(2);
});
expect(calls.some((call) => call.url === 'step-up')).toBe(true);
});
it('скрывает сброс второго фактора при выключенном 2FA', async () => {
const payload = userCardPayload();
payload.totp_enabled = false;
installFetch(
appRoutes([
{
match: '/api/v1/instance/users',
response: () => json({ users: [makeInstanceUser()], total: 1 }),
},
{ match: '/api/v1/instance/users/u-1', response: () => json(payload) },
]),
);
renderApp('/app/empty');
const dialog = await openInstance();
await userEvent.click(await within(dialog).findByTestId('instance-tab-users'));
await userEvent.click(await within(dialog).findByTestId('instance-user-card-u-1'));
const card = await within(dialog).findByTestId('instance-user-card');
expect(within(card).getByTestId('instance-user-card-reset-2fa')).toBeDisabled();
expect(within(card).getByTestId('instance-user-card-totp')).toHaveTextContent(
'Второй фактор выключен',
);
});
});
/** makeInstanceUser — строка списка пользователей панели. */
function makeInstanceUser() {
return {
id: 'u-1',
username: 'card_target',
display_name: 'Цель',
is_instance_admin: false,
badges: [],
created_at: '2026-09-20T10:00:00Z',
banned: false,
};
}