From 39a8b30b038c9a178f1431cff808a1384b33dd81 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 13 Jul 2026 16:08:50 +0300 Subject: [PATCH] Add admin configs endpoint and related UI components - Introduced a new endpoint to list all admin configs, enhancing the admin interface for better management. - Updated API documentation to include the new `/configs` endpoint with pagination and search capabilities. - Added routing and UI elements for the configs section in the admin panel, improving navigation and accessibility. - Enhanced localization for the configs feature in both Russian and English, ensuring a user-friendly experience. --- .../Endpoints/AdminUserEndpoints.cs | 11 ++ .../Admin/Configs/AdminVpnConfigDto.cs | 11 ++ .../Admin/Configs/ListAllConfigsQuery.cs | 11 ++ .../Configs/ListAllConfigsQueryHandler.cs | 58 ++++++ .../ListAllConfigsQueryHandlerTests.cs | 105 +++++++++++ docs/api-design.md | 5 + frontend/src/features/admin/configs/api.ts | 9 + frontend/src/routeTree.gen.ts | 21 +++ frontend/src/routes/admin.tsx | 1 + frontend/src/routes/admin/configs.tsx | 165 ++++++++++++++++++ frontend/src/shared/api/types.ts | 17 ++ frontend/src/shared/lib/i18n.ts | 28 +++ 12 files changed, 442 insertions(+) create mode 100644 backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs create mode 100644 frontend/src/features/admin/configs/api.ts create mode 100644 frontend/src/routes/admin/configs.tsx diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs index 3a3ef45..0f1bd70 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs @@ -1,9 +1,11 @@ using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Configs; using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; using PnvPanel.Application.Configs; +using PnvPanel.Domain.Configs; using PnvPanel.Infrastructure.Identity; namespace PnvPanel.Api.Endpoints; @@ -22,6 +24,7 @@ public static class AdminUserEndpoints admin.MapPost("/users/{id:guid}/reset-password", ResetPassword).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent); admin.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces>(); + admin.MapGet("/configs", ListAllConfigs).Produces>(); admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent); return app; @@ -65,6 +68,14 @@ public static class AdminUserEndpoints return result.ToHttpResult(); } + private static async Task ListAllConfigs( + int page, int pageSize, string? search, ConfigStatus? status, ISender sender, CancellationToken cancellationToken) + { + var query = new ListAllConfigsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, status); + var result = await sender.Send(query, cancellationToken); + return result.ToHttpResult(); + } + private static async Task ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken) { var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken); diff --git a/backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs b/backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs new file mode 100644 index 0000000..33b4ffc --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Configs/AdminVpnConfigDto.cs @@ -0,0 +1,11 @@ +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Application.Admin.Configs; + +/// Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service) +/// содержит владельца и ноду, т.к. список не скоупится одним пользователем. +public sealed record AdminVpnConfigDto( + Guid Id, Guid UserId, string UserName, string? Label, string ClientEmail, VpnProtocol Protocol, + string Location, string NodeName, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt, + ConfigStatus Status, DateTimeOffset CreatedAt); diff --git a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs new file mode 100644 index 0000000..723f904 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Admin.Configs; + +/// матчится по ClientEmail/Label — это то, по чему админ сверяет +/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users). +public sealed record ListAllConfigsQuery(int Page, int PageSize, string? Search, ConfigStatus? Status) + : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs new file mode 100644 index 0000000..9318447 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Configs; + +public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) + : IQueryHandler>> +{ + public async Task>> Handle(ListAllConfigsQuery query, CancellationToken cancellationToken) + { + var page = query.Page <= 0 ? 1 : query.Page; + var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; + + var configsQuery = dbContext.VpnConfigs.AsNoTracking(); + + if (query.Status is { } status) + configsQuery = configsQuery.Where(c => c.Status == status); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + configsQuery = configsQuery.Where(c => c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search))); + } + + var pageResult = await configsQuery + .OrderByDescending(c => c.CreatedAt) + .ToPagedListAsync(page, pageSize, cancellationToken); + + var inboundIds = pageResult.Items.Select(c => c.InboundId).Distinct().ToList(); + var inbounds = (await dbContext.Inbounds.AsNoTracking() + .Where(i => inboundIds.Contains(i.Id)) + .ToListAsync(cancellationToken)) + .ToDictionary(i => i.Id); + + var nodeIds = inbounds.Values.Select(i => i.NodeId).Distinct().ToList(); + var nodes = (await dbContext.Nodes.AsNoTracking() + .Where(n => nodeIds.Contains(n.Id)) + .ToListAsync(cancellationToken)) + .ToDictionary(n => n.Id); + + var userNames = await identityService.GetUserNamesAsync( + pageResult.Items.Select(c => c.UserId).Distinct().ToList(), cancellationToken); + + var items = pageResult.Items.Select(c => + { + var inbound = inbounds.GetValueOrDefault(c.InboundId); + var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId); + return new AdminVpnConfigDto( + c.Id, c.UserId, userNames.GetValueOrDefault(c.UserId, "?"), c.Label, c.ClientEmail, c.Protocol, + inbound?.DisplayName ?? inbound?.Remark ?? "?", node?.Name ?? "?", + c.UsedUpBytes, c.UsedDownBytes, c.ExpiresAt, c.Status, c.CreatedAt); + }).ToList(); + + return Result.Success(new PagedList(items, pageResult.Total, pageResult.Page, pageResult.PageSize)); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs new file mode 100644 index 0000000..2030103 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs @@ -0,0 +1,105 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Configs; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Configs; + +public class ListAllConfigsQueryHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + + [Fact] + public async Task Handle_ReturnsConfigsAcrossUsersWithOwnerNodeAndUserName() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + + var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + inbound.Publish("Germany", [], null); + var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService.GetUserNamesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [userId] = "alice" }); + + var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); + + var result = await handler.Handle(new ListAllConfigsQuery(1, 20, Search: null, Status: null), CancellationToken.None); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal(config.Id, item.Id); + Assert.Equal(userId, item.UserId); + Assert.Equal("alice", item.UserName); + Assert.Equal("node-1", item.NodeName); + Assert.Equal("Germany", item.Location); + Assert.Equal(1, result.Value.Total); + } + + [Fact] + public async Task Handle_FiltersBySearchAcrossClientEmailAndLabel() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + + var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var matching = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "phone-config"); + var other = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "laptop-config"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(matching, other); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService.GetUserNamesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [userId] = "alice" }); + + var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); + + var result = await handler.Handle(new ListAllConfigsQuery(1, 20, Search: "phone", Status: null), CancellationToken.None); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal(matching.Id, item.Id); + } + + [Fact] + public async Task Handle_FiltersByStatus() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + + var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var active = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "active-config"); + var revoked = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "revoked-config"); + revoked.Revoke(); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(active, revoked); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService.GetUserNamesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [userId] = "alice" }); + + var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); + + var result = await handler.Handle(new ListAllConfigsQuery(1, 20, Search: null, Status: ConfigStatus.Revoked), CancellationToken.None); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal(revoked.Id, item.Id); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index c509efe..e460fff 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -170,10 +170,15 @@ approve/reject над `ActivationRequest`. | POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` | | DELETE | `/api/admin/users/{id}` | admin | — | `204 No Content` (отзывает все конфиги пользователя в 3x-ui, затем удаляет учётку; себя удалить нельзя) | | GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` | +| GET | `/api/admin/configs` | admin | query: `page, pageSize, search?, status?` | `PagedList` | | DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) | | GET | `/api/admin/stats` | admin | — | `StatsDto` | | GET | `/api/admin/audit` | admin | query: `page, pageSize` | `PagedList` | +`AdminVpnConfigDto` — глобальный список конфигов для админа (не скоупится одним пользователем, в +отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName, +usedUpBytes, usedDownBytes, expiresAt, status, createdAt }`. `search` матчится по `clientEmail`/`label`. + **Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `isBlocked`. `StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes, totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе, diff --git a/frontend/src/features/admin/configs/api.ts b/frontend/src/features/admin/configs/api.ts new file mode 100644 index 0000000..129d0be --- /dev/null +++ b/frontend/src/features/admin/configs/api.ts @@ -0,0 +1,9 @@ +import { apiRequest } from '@/shared/api/client' +import type { AdminVpnConfigDto, ConfigStatus, PagedList } from '@/shared/api/types' + +export function listAllConfigs(page: number, pageSize: number, search: string | undefined, status: ConfigStatus | undefined) { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) + if (search) params.set('search', search) + if (status) params.set('status', status) + return apiRequest>(`/admin/configs?${params.toString()}`) +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 51de255..354ee5a 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as AdminUsersRouteImport } from './routes/admin/users' import { Route as AdminRolesRouteImport } from './routes/admin/roles' import { Route as AdminNodesRouteImport } from './routes/admin/nodes' import { Route as AdminNewsRouteImport } from './routes/admin/news' +import { Route as AdminConfigsRouteImport } from './routes/admin/configs' import { Route as AdminAuditRouteImport } from './routes/admin/audit' import { Route as AdminAppsRouteImport } from './routes/admin/apps' import { Route as AdminActivationRouteImport } from './routes/admin/activation' @@ -91,6 +92,11 @@ const AdminNewsRoute = AdminNewsRouteImport.update({ path: '/news', getParentRoute: () => AdminRoute, } as any) +const AdminConfigsRoute = AdminConfigsRouteImport.update({ + id: '/configs', + path: '/configs', + getParentRoute: () => AdminRoute, +} as any) const AdminAuditRoute = AdminAuditRouteImport.update({ id: '/audit', path: '/audit', @@ -119,6 +125,7 @@ export interface FileRoutesByFullPath { '/admin/activation': typeof AdminActivationRoute '/admin/apps': typeof AdminAppsRoute '/admin/audit': typeof AdminAuditRoute + '/admin/configs': typeof AdminConfigsRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute '/admin/roles': typeof AdminRolesRoute @@ -136,6 +143,7 @@ export interface FileRoutesByTo { '/admin/activation': typeof AdminActivationRoute '/admin/apps': typeof AdminAppsRoute '/admin/audit': typeof AdminAuditRoute + '/admin/configs': typeof AdminConfigsRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute '/admin/roles': typeof AdminRolesRoute @@ -155,6 +163,7 @@ export interface FileRoutesById { '/admin/activation': typeof AdminActivationRoute '/admin/apps': typeof AdminAppsRoute '/admin/audit': typeof AdminAuditRoute + '/admin/configs': typeof AdminConfigsRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute '/admin/roles': typeof AdminRolesRoute @@ -175,6 +184,7 @@ export interface FileRouteTypes { | '/admin/activation' | '/admin/apps' | '/admin/audit' + | '/admin/configs' | '/admin/news' | '/admin/nodes' | '/admin/roles' @@ -192,6 +202,7 @@ export interface FileRouteTypes { | '/admin/activation' | '/admin/apps' | '/admin/audit' + | '/admin/configs' | '/admin/news' | '/admin/nodes' | '/admin/roles' @@ -210,6 +221,7 @@ export interface FileRouteTypes { | '/admin/activation' | '/admin/apps' | '/admin/audit' + | '/admin/configs' | '/admin/news' | '/admin/nodes' | '/admin/roles' @@ -321,6 +333,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminNewsRouteImport parentRoute: typeof AdminRoute } + '/admin/configs': { + id: '/admin/configs' + path: '/configs' + fullPath: '/admin/configs' + preLoaderRoute: typeof AdminConfigsRouteImport + parentRoute: typeof AdminRoute + } '/admin/audit': { id: '/admin/audit' path: '/audit' @@ -349,6 +368,7 @@ interface AdminRouteChildren { AdminActivationRoute: typeof AdminActivationRoute AdminAppsRoute: typeof AdminAppsRoute AdminAuditRoute: typeof AdminAuditRoute + AdminConfigsRoute: typeof AdminConfigsRoute AdminNewsRoute: typeof AdminNewsRoute AdminNodesRoute: typeof AdminNodesRoute AdminRolesRoute: typeof AdminRolesRoute @@ -360,6 +380,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminActivationRoute: AdminActivationRoute, AdminAppsRoute: AdminAppsRoute, AdminAuditRoute: AdminAuditRoute, + AdminConfigsRoute: AdminConfigsRoute, AdminNewsRoute: AdminNewsRoute, AdminNodesRoute: AdminNodesRoute, AdminRolesRoute: AdminRolesRoute, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 71620e9..8083fc5 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -9,6 +9,7 @@ const TABS = [ { to: '/admin', key: 'overview' }, { to: '/admin/activation', key: 'activation' }, { to: '/admin/users', key: 'users' }, + { to: '/admin/configs', key: 'configs' }, { to: '/admin/roles', key: 'roles' }, { to: '/admin/nodes', key: 'nodes' }, { to: '/admin/apps', key: 'apps' }, diff --git a/frontend/src/routes/admin/configs.tsx b/frontend/src/routes/admin/configs.tsx new file mode 100644 index 0000000..d9ba1de --- /dev/null +++ b/frontend/src/routes/admin/configs.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Trash2 } from 'lucide-react' +import { toast } from '@/shared/ui/toast-store' +import { Input } from '@/shared/ui/input' +import { Button } from '@/shared/ui/button' +import { Badge } from '@/shared/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { formatBytes } from '@/shared/lib/format' +import { listAllConfigs } from '@/features/admin/configs/api' +import { forceRevokeConfig } from '@/features/admin/users/api' +import type { ConfigStatus } from '@/shared/api/types' + +export const Route = createFileRoute('/admin/configs')({ component: AdminConfigsPage }) + +const PAGE_SIZE = 20 + +const STATUS_VARIANT: Record = { + Active: 'success', + Disabled: 'warning', + Expired: 'destructive', + LimitReached: 'destructive', + Revoked: 'destructive', +} + +function AdminConfigsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [search, setSearch] = useState('') + const [status, setStatus] = useState('all') + const [page, setPage] = useState(1) + + const statusFilter = status === 'all' ? undefined : status + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['admin-configs', page, search, statusFilter], + queryFn: () => listAllConfigs(page, PAGE_SIZE, search || undefined, statusFilter), + }) + + const revokeMutation = useMutation({ + mutationFn: (configId: string) => forceRevokeConfig(configId), + onSuccess: async () => { + toast.success(t('configs.revoked')) + await queryClient.invalidateQueries({ queryKey: ['admin-configs'] }) + }, + onError: () => toast.error(t('auth.genericError')), + }) + + return ( +
+
+ { + setSearch(e.target.value) + setPage(1) + }} + className="max-w-sm" + /> + +
+ + {isLoading &&

} + + {isError && ( +
+

{t('auth.genericError')}

+ +
+ )} + + {data && ( + <> +
+ + + + + + + + + + + + + {data.items.map((config) => ( + + + + + + + + + + ))} + +
{t('admin.configs.owner')}{t('admin.users.configs')}{t('admin.configs.protocol')}{t('admin.configs.node')}{t('admin.configs.traffic')}{t('admin.configs.statusLabel')} +
{config.userName} +
{config.label ?? config.clientEmail}
+
{config.clientEmail}
+
+ {config.protocol} + + {config.nodeName} · {config.location} + + ↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)} + + {t(`configs.status.${config.status}`)} + + +
+
+ + {data.items.length === 0 &&

{t('admin.configs.empty')}

} + +
+ {t('admin.configs.total', { count: data.total })} +
+ + +
+
+ + )} +
+ ) +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 33bdcf1..e519e33 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -59,6 +59,23 @@ export type VpnConfigDto = { createdAt: string } +/** Строка админского списка «Все конфиги» — в отличие от VpnConfigDto содержит владельца и ноду. */ +export type AdminVpnConfigDto = { + id: string + userId: string + userName: string + label: string | null + clientEmail: string + protocol: VpnProtocol + location: string + nodeName: string + usedUpBytes: number + usedDownBytes: number + expiresAt: string | null + status: ConfigStatus + createdAt: string +} + export type AvailableInboundDto = { inboundId: string displayName: string diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index ffd9f52..312ae41 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -154,6 +154,7 @@ const resources = { overview: 'Обзор', activation: 'Запросы на активацию', users: 'Пользователи', + configs: 'Все конфиги', roles: 'Роли', nodes: 'Ноды', apps: 'Приложения', @@ -187,6 +188,19 @@ const resources = { confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.', deleted: 'Пользователь удалён.', }, + configs: { + searchPlaceholder: 'Поиск по email в панели или метке', + owner: 'Владелец', + node: 'Нода', + location: 'Локация', + protocol: 'Протокол', + traffic: 'Трафик', + statusLabel: 'Статус', + allStatuses: 'Все статусы', + created: 'Создан', + empty: 'Конфиги не найдены.', + total: 'Всего: {{count}}', + }, activation: { empty: 'Нет ожидающих запросов на активацию.', approved: 'Пользователь активирован.', @@ -447,6 +461,7 @@ const resources = { overview: 'Overview', activation: 'Activation requests', users: 'Users', + configs: 'All configs', roles: 'Roles', nodes: 'Nodes', apps: 'Apps', @@ -480,6 +495,19 @@ const resources = { confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.', deleted: 'User deleted.', }, + configs: { + searchPlaceholder: 'Search by panel email or label', + owner: 'Owner', + node: 'Node', + location: 'Location', + protocol: 'Protocol', + traffic: 'Traffic', + statusLabel: 'Status', + allStatuses: 'All statuses', + created: 'Created', + empty: 'No configs found.', + total: 'Total: {{count}}', + }, activation: { empty: 'No pending activation requests.', approved: 'User activated.',