diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs index eafe66c..20dc00f 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs @@ -53,7 +53,13 @@ public static class AdminBillingEndpoints CancellationToken cancellationToken ) { - var query = new ListPaymentRequestsQuery(request.Status, request.Page, request.PageSize); + var query = new ListPaymentRequestsQuery( + request.Status, + request.Kind, + request.Search, + request.Page, + request.PageSize + ); var result = await sender.Send(query, cancellationToken); return result.ToHttpResult(); } @@ -98,6 +104,8 @@ public static class AdminBillingEndpoints public sealed record ListPaymentRequestsRequest( PaymentRequestStatus? Status, + PaymentRequestKind? Kind, + string? Search, int Page = 1, int PageSize = 20 ); diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs index a585d49..3114758 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminStatsEndpoints.cs @@ -3,6 +3,7 @@ using PnvPanel.Application.Admin.Audit; using PnvPanel.Application.Admin.Stats; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Audit; using PnvPanel.Infrastructure.Identity; namespace PnvPanel.Api.Endpoints; @@ -30,11 +31,20 @@ public static class AdminStatsEndpoints private static async Task GetAudit( int page, int pageSize, + AuditSource? source, + string? targetType, + string? action, ISender sender, CancellationToken cancellationToken ) { - var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize); + var query = new ListAuditLogsQuery( + page <= 0 ? 1 : page, + pageSize <= 0 ? 50 : pageSize, + source, + targetType, + action + ); var result = await sender.Send(query, cancellationToken); return result.ToHttpResult(); } diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs index 85f183d..e7eb9b5 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminUserEndpoints.cs @@ -6,6 +6,7 @@ using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; using PnvPanel.Application.Configs; using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; using PnvPanel.Infrastructure.Identity; namespace PnvPanel.Api.Endpoints; @@ -44,11 +45,23 @@ public static class AdminUserEndpoints int page, int pageSize, string? search, + Guid? roleId, + bool? isActivated, + bool? isBlocked, + bool? billingExpired, ISender sender, CancellationToken cancellationToken ) { - var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search); + var query = new ListUsersQuery( + page <= 0 ? 1 : page, + pageSize <= 0 ? 20 : pageSize, + search, + roleId, + isActivated, + isBlocked, + billingExpired + ); var result = await sender.Send(query, cancellationToken); return result.ToHttpResult(); } @@ -112,6 +125,8 @@ public static class AdminUserEndpoints int pageSize, string? search, ConfigStatus? status, + VpnProtocol? protocol, + Guid? nodeId, ISender sender, CancellationToken cancellationToken ) @@ -120,7 +135,9 @@ public static class AdminUserEndpoints page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, - status + status, + protocol, + nodeId ); var result = await sender.Send(query, cancellationToken); return result.ToHttpResult(); diff --git a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs index 928c57f..108d19d 100644 --- a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs @@ -15,6 +15,7 @@ public static class InboundEndpoints admin.MapGet("", ListInbounds).Produces>(); admin.MapPut("/{id:guid}/publish", PublishInbound).Produces(); + admin.MapDelete("/{id:guid}", DeleteInbound); return app; } @@ -45,6 +46,16 @@ public static class InboundEndpoints var result = await sender.Send(command, cancellationToken); return result.ToHttpResult(); } + + private static async Task DeleteInbound( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteInboundCommand(id), cancellationToken); + return result.ToHttpResult(); + } } public sealed record PublishInboundBody( diff --git a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs index cee2f38..f6c865c 100644 --- a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs +++ b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQuery.cs @@ -4,8 +4,13 @@ using PnvPanel.Domain.Audit; namespace PnvPanel.Application.Admin.Audit; -public sealed record ListAuditLogsQuery(int Page, int PageSize) - : IQuery>>; +public sealed record ListAuditLogsQuery( + int Page, + int PageSize, + AuditSource? Source, + string? TargetType, + string? Action +) : IQuery>>; public sealed record AuditLogDto( long Id, diff --git a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs index 1897c4f..83665ee 100644 --- a/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Audit/ListAuditLogsQueryHandler.cs @@ -16,8 +16,16 @@ public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) var page = query.Page <= 0 ? 1 : query.Page; var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize; - var result = await dbContext - .AuditLogs.AsNoTracking() + var logsQuery = dbContext.AuditLogs.AsNoTracking(); + + if (query.Source is { } source) + logsQuery = logsQuery.Where(a => a.Source == source); + if (!string.IsNullOrWhiteSpace(query.TargetType)) + logsQuery = logsQuery.Where(a => a.TargetType == query.TargetType); + if (!string.IsNullOrWhiteSpace(query.Action)) + logsQuery = logsQuery.Where(a => a.Action.Contains(query.Action)); + + var result = await logsQuery .OrderByDescending(a => a.CreatedAt) .Select(a => new AuditLogDto( a.Id, diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQuery.cs b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQuery.cs index 25ff9bd..8f18c1c 100644 --- a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQuery.cs +++ b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQuery.cs @@ -4,5 +4,10 @@ using PnvPanel.Domain.Billing; namespace PnvPanel.Application.Admin.Billing; -public sealed record ListPaymentRequestsQuery(PaymentRequestStatus? StatusFilter, int Page, int PageSize) - : IQuery>>; +public sealed record ListPaymentRequestsQuery( + PaymentRequestStatus? StatusFilter, + PaymentRequestKind? KindFilter, + string? Search, + int Page, + int PageSize +) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs index f99fdc3..71d514c 100644 --- a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs @@ -21,6 +21,18 @@ public sealed class ListPaymentRequestsQueryHandler( var requestsQuery = dbContext.PaymentRequests.AsNoTracking(); if (query.StatusFilter is { } status) requestsQuery = requestsQuery.Where(r => r.Status == status); + if (query.KindFilter is { } kind) + requestsQuery = requestsQuery.Where(r => r.Kind == kind); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + // PaymentRequest хранит только UserId — резолвим совпадающих пользователей ДО пагинации + // (иначе "поиск по имени" фильтровал бы уже отобранную страницу, а не весь набор). + var matchingUserIds = await identityService.FindUserIdsByUserNameAsync( + query.Search.Trim(), + cancellationToken + ); + requestsQuery = requestsQuery.Where(r => matchingUserIds.Contains(r.UserId)); + } var page1 = await requestsQuery .OrderByDescending(r => r.CreatedAt) diff --git a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs index a61fc0e..a1d2a3f 100644 --- a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs +++ b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQuery.cs @@ -2,14 +2,18 @@ using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; namespace PnvPanel.Application.Admin.Configs; /// матчится по ClientEmail/Label — это то, по чему админ сверяет -/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users). +/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users). +/// фильтрует по ноде инбаунда конфига (JOIN Inbounds). public sealed record ListAllConfigsQuery( int Page, int PageSize, string? Search, - ConfigStatus? Status + ConfigStatus? Status, + VpnProtocol? Protocol, + Guid? NodeId ) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs index b3c7ad0..1678eda 100644 --- a/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Configs/ListAllConfigsQueryHandler.cs @@ -23,6 +23,19 @@ public sealed class ListAllConfigsQueryHandler( if (query.Status is { } status) configsQuery = configsQuery.Where(c => c.Status == status); + if (query.Protocol is { } protocol) + configsQuery = configsQuery.Where(c => c.Protocol == protocol); + + if (query.NodeId is { } nodeId) + { + var nodeInboundIds = await dbContext + .Inbounds.AsNoTracking() + .Where(i => i.NodeId == nodeId) + .Select(i => i.Id) + .ToListAsync(cancellationToken); + configsQuery = configsQuery.Where(c => nodeInboundIds.Contains(c.InboundId)); + } + if (!string.IsNullOrWhiteSpace(query.Search)) { var search = query.Search.Trim(); diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/DeleteInboundCommand.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/DeleteInboundCommand.cs new file mode 100644 index 0000000..e885b96 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/DeleteInboundCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +/// Force-удаление недоступного на панели инбаунда вместе с каскадным отзывом ещё живых +/// конфигов на нём — см. DeleteInboundCommandHandler. +public sealed record DeleteInboundCommand(Guid InboundId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/DeleteInboundCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/DeleteInboundCommandHandler.cs new file mode 100644 index 0000000..0188d0d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/DeleteInboundCommandHandler.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Admin.Inbounds; + +/// Удаляет инбаунд, которого больше нет на панели (см. SyncNodeCommandHandler — +/// автоматически он не удаляется, пока по нему есть VpnConfig, чтобы не потерять ссылку на историю). +/// Разрешено только для `IsAvailable == false` — живой, всё ещё синхронизируемый инбаунд через этот +/// путь не удалить. Перед удалением каскадно отзывает все ещё не-Revoked конфиги на нём: панельный +/// клиент всё равно недостижим (инбаунда для него на 3x-ui уже нет), поэтому Revoke — чисто локальная +/// операция, без вызова гейтвея (см. RevokeVpnConfigCommandHandler для того же паттерна). Уже +/// Revoked-конфиги при этом остаются в БД с InboundId, указывающим на удалённую запись — это +/// осознанный компромисс (см. domain-model.md): нигде в пользовательских списках Revoked-конфиги не +/// показываются, а в админском списке отсутствующий инбаунд отображается как "?". +public sealed class DeleteInboundCommandHandler( + IAppDbContext dbContext, + IRealtimeNotifier notifier, + ICurrentUser currentUser +) : ICommandHandler +{ + public async Task Handle(DeleteInboundCommand command, CancellationToken cancellationToken) + { + var inbound = await dbContext.Inbounds.FirstOrDefaultAsync( + i => i.Id == command.InboundId, + cancellationToken + ); + if (inbound is null) + return Result.Failure(InboundErrors.NotFound); + + if (inbound.IsAvailable) + return Result.Failure(InboundErrors.StillAvailable); + + var configs = await dbContext + .VpnConfigs.Where(c => c.InboundId == inbound.Id && c.Status != ConfigStatus.Revoked) + .ToListAsync(cancellationToken); + + foreach (var config in configs) + { + config.Revoke(); + await notifier.NotifyConfigStatusChangedAsync( + config.UserId, + config.Id, + config.Status, + cancellationToken + ); + } + + dbContext.Inbounds.Remove(inbound); + + dbContext.AuditLogs.Add( + AuditLog.Create( + currentUser.UserId, + "InboundDeleted", + "Inbound", + inbound.Id.ToString(), + metadata: configs.Count > 0 ? $"{{\"revokedConfigs\":{configs.Count}}}" : null, + AuditSource.Web + ) + ); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs index 4d7a472..3567619 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs @@ -8,4 +8,9 @@ public static class InboundErrors "Inbounds.NotFound", "Inbound не найден." ); + + public static readonly Error StillAvailable = Error.Validation( + "Inbounds.StillAvailable", + "Inbound всё ещё существует на панели — удалить можно только недоступные (IsAvailable=false)." + ); } diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs index b8120be..d335463 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQuery.cs @@ -4,5 +4,12 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Admin.Users; -public sealed record ListUsersQuery(int Page, int PageSize, string? Search) - : IQuery>>; +public sealed record ListUsersQuery( + int Page, + int PageSize, + string? Search, + Guid? RoleId, + bool? IsActivated, + bool? IsBlocked, + bool? BillingExpired +) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs index 104f74d..ae77375 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/ListUsersQueryHandler.cs @@ -21,6 +21,10 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService, IApp page, pageSize, query.Search, + query.RoleId, + query.IsActivated, + query.IsBlocked, + query.BillingExpired, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index dee433f..cc97a3d 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -92,6 +92,14 @@ public interface IIdentityService CancellationToken cancellationToken ); + /// Для фильтрации списков по владельцу там, где сущность хранит только UserId (см. + /// ListPaymentRequestsQueryHandler) — резолвится ДО пагинации, а не после (в отличие от + /// GetUserNamesAsync, который просто подставляет имена в уже отобранную страницу). + Task> FindUserIdsByUserNameAsync( + string search, + CancellationToken cancellationToken + ); + /// Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной. Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken); @@ -117,6 +125,10 @@ public interface IIdentityService int page, int pageSize, string? search, + Guid? roleId, + bool? isActivated, + bool? isBlocked, + bool? billingExpired, CancellationToken cancellationToken ); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index abfbdfe..b51d34b 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -4,13 +4,15 @@ using Microsoft.EntityFrameworkCore; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Models; +using PnvPanel.Infrastructure.Persistence; namespace PnvPanel.Infrastructure.Identity; internal sealed class IdentityService( UserManager userManager, SignInManager signInManager, - RoleManager roleManager + RoleManager roleManager, + AppDbContext dbContext ) : IIdentityService { public async Task> CreateUserAsync( @@ -176,6 +178,17 @@ internal sealed class IdentityService( .ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken); } + public async Task> FindUserIdsByUserNameAsync( + string search, + CancellationToken cancellationToken + ) + { + return await userManager + .Users.Where(u => u.UserName!.Contains(search)) + .Select(u => u.Id) + .ToListAsync(cancellationToken); + } + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) { var user = await userManager.FindByIdAsync(userId.ToString()); @@ -252,12 +265,36 @@ internal sealed class IdentityService( int page, int pageSize, string? search, + Guid? roleId, + bool? isActivated, + bool? isBlocked, + bool? billingExpired, CancellationToken cancellationToken ) { var query = userManager.Users.AsNoTracking(); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(u => u.UserName!.Contains(search)); + if (isActivated is { } activated) + query = query.Where(u => u.IsActivated == activated); + if (isBlocked is { } blocked) + query = query.Where(u => u.IsBlocked == blocked); + if (roleId is { } roleIdFilter) + query = query.Where(u => dbContext.UserRoles.Any(ur => ur.UserId == u.Id && ur.RoleId == roleIdFilter)); + if (billingExpired is { } expired) + { + // Только среди пользователей с billing-ролью — иначе сюда попали бы и те, у кого + // биллинг вообще не включён (BillingPaidUntil у них тоже null, но это не "просрочено"). + var now = DateTimeOffset.UtcNow; + query = query.Where(u => + dbContext.UserRoles.Any(ur => + ur.UserId == u.Id && dbContext.Roles.Any(r => r.Id == ur.RoleId && r.BillingEnabled) + ) + ); + query = expired + ? query.Where(u => u.BillingPaidUntil == null || u.BillingPaidUntil < now) + : query.Where(u => u.BillingPaidUntil != null && u.BillingPaidUntil >= now); + } var total = await query.CountAsync(cancellationToken); var users = await query diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Audit/ListAuditLogsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Audit/ListAuditLogsQueryHandlerTests.cs new file mode 100644 index 0000000..e7e7ecc --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Audit/ListAuditLogsQueryHandlerTests.cs @@ -0,0 +1,75 @@ +using PnvPanel.Application.Admin.Audit; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Audit; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Audit; + +public class ListAuditLogsQueryHandlerTests +{ + [Fact] + public async Task Handle_FiltersBySource() + { + using var dbContext = InMemoryDbContextFactory.Create(); + dbContext.AuditLogs.AddRange( + AuditLog.Create(null, "BillingSuspended", "User", Guid.NewGuid().ToString(), null, AuditSource.System), + AuditLog.Create(Guid.NewGuid(), "InboundDeleted", "Inbound", Guid.NewGuid().ToString(), null, AuditSource.Web) + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ListAuditLogsQueryHandler(dbContext); + + var result = await handler.Handle( + new ListAuditLogsQuery(1, 50, AuditSource.System, TargetType: null, Action: null), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal("BillingSuspended", item.Action); + } + + [Fact] + public async Task Handle_FiltersByTargetType() + { + using var dbContext = InMemoryDbContextFactory.Create(); + dbContext.AuditLogs.AddRange( + AuditLog.Create(Guid.NewGuid(), "UserBlocked", "User", Guid.NewGuid().ToString(), null, AuditSource.Web), + AuditLog.Create(Guid.NewGuid(), "InboundDeleted", "Inbound", Guid.NewGuid().ToString(), null, AuditSource.Web) + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ListAuditLogsQueryHandler(dbContext); + + var result = await handler.Handle( + new ListAuditLogsQuery(1, 50, Source: null, TargetType: "Inbound", Action: null), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal("InboundDeleted", item.Action); + } + + [Fact] + public async Task Handle_FiltersByActionSubstring() + { + using var dbContext = InMemoryDbContextFactory.Create(); + dbContext.AuditLogs.AddRange( + AuditLog.Create(Guid.NewGuid(), "TicketResolved", "SupportTicket", Guid.NewGuid().ToString(), null, AuditSource.Web), + AuditLog.Create(Guid.NewGuid(), "TicketClosed", "SupportTicket", Guid.NewGuid().ToString(), null, AuditSource.Web) + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ListAuditLogsQueryHandler(dbContext); + + var result = await handler.Handle( + new ListAuditLogsQuery(1, 50, Source: null, TargetType: null, Action: "Resolved"), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal("TicketResolved", item.Action); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ListPaymentRequestsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ListPaymentRequestsQueryHandlerTests.cs new file mode 100644 index 0000000..5ae0ed7 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ListPaymentRequestsQueryHandlerTests.cs @@ -0,0 +1,81 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Billing; + +public class ListPaymentRequestsQueryHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + + [Fact] + public async Task Handle_FiltersByKind() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var subscription = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); + var topUp = PaymentRequest.CreateRoleChangeTopUp(userId, 500); + dbContext.PaymentRequests.AddRange(subscription, topUp); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetUserNamesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [userId] = "alice" }); + + var handler = new ListPaymentRequestsQueryHandler(dbContext, _identityService); + + var result = await handler.Handle( + new ListPaymentRequestsQuery( + StatusFilter: null, + KindFilter: PaymentRequestKind.RoleChangeTopUp, + Search: null, + Page: 1, + PageSize: 20 + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal(topUp.Id, item.Id); + } + + [Fact] + public async Task Handle_FiltersBySearch_ResolvesUserIdsBeforePagination() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var aliceId = Guid.NewGuid(); + var bobId = Guid.NewGuid(); + var aliceRequest = PaymentRequest.Create(aliceId, PaymentPeriod.Quarter, 1500); + var bobRequest = PaymentRequest.Create(bobId, PaymentPeriod.Quarter, 1500); + dbContext.PaymentRequests.AddRange(aliceRequest, bobRequest); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .FindUserIdsByUserNameAsync("alice", Arg.Any()) + .Returns([aliceId]); + _identityService + .GetUserNamesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [aliceId] = "alice" }); + + var handler = new ListPaymentRequestsQueryHandler(dbContext, _identityService); + + var result = await handler.Handle( + new ListPaymentRequestsQuery( + StatusFilter: null, + KindFilter: null, + Search: "alice", + Page: 1, + PageSize: 20 + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal(aliceRequest.Id, item.Id); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs index 8f015ff..db61352 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs @@ -41,7 +41,7 @@ public class ListAllConfigsQueryHandlerTests var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); var result = await handler.Handle( - new ListAllConfigsQuery(1, 20, Search: null, Status: null), + new ListAllConfigsQuery(1, 20, Search: null, Status: null, Protocol: null, NodeId: null), CancellationToken.None ); @@ -83,7 +83,7 @@ public class ListAllConfigsQueryHandlerTests var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); var result = await handler.Handle( - new ListAllConfigsQuery(1, 20, Search: "phone", Status: null), + new ListAllConfigsQuery(1, 20, Search: "phone", Status: null, Protocol: null, NodeId: null), CancellationToken.None ); @@ -121,7 +121,7 @@ public class ListAllConfigsQueryHandlerTests var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); var result = await handler.Handle( - new ListAllConfigsQuery(1, 20, Search: null, Status: ConfigStatus.Revoked), + new ListAllConfigsQuery(1, 20, Search: null, Status: ConfigStatus.Revoked, Protocol: null, NodeId: null), CancellationToken.None ); @@ -129,4 +129,85 @@ public class ListAllConfigsQueryHandlerTests var item = Assert.Single(result.Value.Items); Assert.Equal(revoked.Id, item.Id); } + + [Fact] + public async Task Handle_FiltersByProtocol() + { + 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 vless = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "vless-config"); + var trojan = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Trojan, "trojan-config"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(vless, trojan); + 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, Protocol: VpnProtocol.Trojan, NodeId: null), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal(trojan.Id, item.Id); + } + + [Fact] + public async Task Handle_FiltersByNodeId() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + + var nodeA = Node.Register( + "node-a", + new Uri("https://node-a.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + var nodeB = Node.Register( + "node-b", + new Uri("https://node-b.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + var inboundA = Inbound.FromRemote(nodeA.Id, "1", VpnProtocol.Vless, "remark", 443); + var inboundB = Inbound.FromRemote(nodeB.Id, "1", VpnProtocol.Vless, "remark", 443); + var configA = VpnConfig.Create(userId, inboundA.Id, VpnProtocol.Vless, "config-a"); + var configB = VpnConfig.Create(userId, inboundB.Id, VpnProtocol.Vless, "config-b"); + + dbContext.Nodes.AddRange(nodeA, nodeB); + dbContext.Inbounds.AddRange(inboundA, inboundB); + dbContext.VpnConfigs.AddRange(configA, configB); + 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, Protocol: null, NodeId: nodeB.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var item = Assert.Single(result.Value.Items); + Assert.Equal(configB.Id, item.Id); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/DeleteInboundCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/DeleteInboundCommandHandlerTests.cs new file mode 100644 index 0000000..31ea9ba --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/DeleteInboundCommandHandlerTests.cs @@ -0,0 +1,88 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Inbounds; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Inbounds; + +public class DeleteInboundCommandHandlerTests +{ + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ICurrentUser _currentUser = Substitute.For(); + + private DeleteInboundCommandHandler CreateHandler(IAppDbContext dbContext) => + new(dbContext, _notifier, _currentUser); + + [Fact] + public async Task Handle_WhenInboundNotFound_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var result = await CreateHandler(dbContext) + .Handle(new DeleteInboundCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(InboundErrors.NotFound, result.Error); + } + + [Fact] + public async Task Handle_WhenInboundStillAvailable_ReturnsStillAvailable() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); + dbContext.Inbounds.Add(inbound); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var result = await CreateHandler(dbContext) + .Handle(new DeleteInboundCommand(inbound.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(InboundErrors.StillAvailable, result.Error); + Assert.NotNull(await dbContext.Inbounds.FindAsync([inbound.Id], CancellationToken.None)); + } + + [Fact] + public async Task Handle_WhenUnavailable_RevokesActiveConfigsAndDeletesInbound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); + inbound.MarkUnavailable(); + + var userId = Guid.NewGuid(); + var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null); + var alreadyRevoked = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null); + alreadyRevoked.Revoke(); + + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(activeConfig, alreadyRevoked); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var result = await CreateHandler(dbContext) + .Handle(new DeleteInboundCommand(inbound.Id), CancellationToken.None); + // UnitOfWorkBehavior делает это в реальном пайплайне — здесь хендлер вызывается напрямую. + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Revoked, activeConfig.Status); + Assert.Null(await dbContext.Inbounds.FindAsync([inbound.Id], CancellationToken.None)); + await _notifier + .Received(1) + .NotifyConfigStatusChangedAsync( + userId, + activeConfig.Id, + ConfigStatus.Revoked, + Arg.Any() + ); + await _notifier + .DidNotReceive() + .NotifyConfigStatusChangedAsync( + userId, + alreadyRevoked.Id, + Arg.Any(), + Arg.Any() + ); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs index 86a80e2..3dd50d7 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs @@ -26,12 +26,12 @@ public class ListUsersQueryHandlerTests await dbContext.SaveChangesAsync(CancellationToken.None); _identityService - .ListUsersAsync(1, 20, null, Arg.Any()) + .ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any()) .Returns(new PagedList([Summary(userId)], 1, 1, 20)); var handler = new ListUsersQueryHandler(_identityService, dbContext); - var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None); + var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None); Assert.True(result.IsSuccess); Assert.True(result.Value.Items.Single().BillingPendingReview); @@ -44,12 +44,12 @@ public class ListUsersQueryHandlerTests var userId = Guid.NewGuid(); _identityService - .ListUsersAsync(1, 20, null, Arg.Any()) + .ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any()) .Returns(new PagedList([Summary(userId)], 1, 1, 20)); var handler = new ListUsersQueryHandler(_identityService, dbContext); - var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None); + var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None); Assert.True(result.IsSuccess); Assert.False(result.Value.Items.Single().BillingPendingReview); @@ -68,12 +68,12 @@ public class ListUsersQueryHandlerTests await dbContext.SaveChangesAsync(CancellationToken.None); _identityService - .ListUsersAsync(1, 20, null, Arg.Any()) + .ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any()) .Returns(new PagedList([Summary(userId)], 1, 1, 20)); var handler = new ListUsersQueryHandler(_identityService, dbContext); - var result = await handler.Handle(new ListUsersQuery(1, 20, null), CancellationToken.None); + var result = await handler.Handle(new ListUsersQuery(1, 20, null, null, null, null, null), CancellationToken.None); Assert.True(result.IsSuccess); Assert.False(result.Value.Items.Single().BillingPendingReview); diff --git a/docs/api-design.md b/docs/api-design.md index 0bf3fb6..1a99649 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -278,7 +278,7 @@ approve/reject над `ActivationRequest`. | ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- | | GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | | PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` | -| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) | +| GET | `/api/admin/billing/requests` | admin | query: `status?, kind?, search?, page=1, pageSize=20` (`search` — по имени пользователя, резолвится до пагинации) | `PagedList` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) | | POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (для `Kind.Subscription` продлевает `BillingPaidUntil` и возвращает приостановленные конфиги в `Active`; для `Kind.RoleChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#rolechangetopup) | | POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` | | POST | `/api/admin/billing/gift` | admin | `{ userId, days }` | `204 No Content` (продлевает `BillingPaidUntil` на `days` от `max(текущий, сейчас)`, возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; `403 Billing.NotEnabled`, если роль пользователя не billing) | @@ -308,21 +308,22 @@ approve/reject над `ActivationRequest`. | ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- | | GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` | | PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds? }` | `InboundDto` | +| DELETE | `/api/admin/inbounds/{id}` | admin | только для `IsAvailable=false`, иначе `Inbounds.StillAvailable` | `204` | ## Admin — Users & Stats | Метод | Путь | Роль | Тело запроса | Тело ответа | | ------ | ---------------------------------------- | ----- | --------------------------- | ------------- | -| GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `PagedList` | +| GET | `/api/admin/users` | admin | query: `page, pageSize, search?, roleId?, isActivated?, isBlocked?, billingExpired?` | `PagedList` | | PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` | | PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` | | 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` | +| GET | `/api/admin/configs` | admin | query: `page, pageSize, search?, status?, protocol?, nodeId?` | `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` | +| GET | `/api/admin/audit` | admin | query: `page, pageSize, source?, targetType?, action?` (`action` — подстрока) | `PagedList` | `AdminVpnConfigDto` — глобальный список конфигов для админа (не скоупится одним пользователем, в отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName, diff --git a/docs/domain-model.md b/docs/domain-model.md index 81aae34..1ccfbc3 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -88,6 +88,17 @@ AppUser `IsAvailable` сбрасывается обратно в `true`: без этого разово пропавший инбаунд оставался бы недоступным навсегда, даже вернувшись на панель. +Пока запись висит с `IsAvailable=false`, админ может удалить её вручную +(`DELETE /api/admin/inbounds/{id}`, `DeleteInboundCommandHandler`) — это единственный способ +вычистить дубли, возникающие, если админ пересоздал инбаунд на самой панели под тем же remark/портом +(3x-ui выдаёт новый `RemoteInboundId`, старая запись остаётся мусором навсегда, пока её не удалить +руками). Разрешено только для `IsAvailable=false` — на живой инбаунд эта команда не действует +(`Inbounds.StillAvailable`). Перед удалением каскадно отзывает (`VpnConfig.Revoke()`) все ещё не +`Revoked` конфиги на нём — панельного клиента для них всё равно не существует, поэтому это чисто +локальная операция без вызова гейтвея. Уже `Revoked` конфиги при этом остаются в БД с `InboundId`, +указывающим на удалённую запись — сознательный компромисс: пользовательские списки конфигов Revoked +не показывают вовсе, а админский список подставляет "?" вместо локации отсутствующего инбаунда. + > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический > индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать > `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов @@ -577,6 +588,13 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты знает про `PaymentRequest` (граница Identity/биллинг), поэтому джойн с `PaymentRequests` сделан в Application-хендлере поверх результата `IIdentityService.ListUsersAsync`, а не внутри Identity. +`ListUsersAsync` также поддерживает фильтр `billingExpired` (`GET /api/admin/users`) — но не просто +`BillingPaidUntil < now`: у пользователя без billing-роли это поле тоже `null`, что не значит +"просрочено". Фильтр дополнительно ограничивает выборку пользователями, чья роль имеет +`BillingEnabled=true` (джойн `AspNetUserRoles`/`AspNetRoles` внутри `IdentityService`, единственное +место в Identity, которое смотрит на `AppRole.BillingEnabled`, — не на `PaymentRequest`, так что +граница Identity/биллинг не нарушается). + Прочие точки, не входящие в `BillingConfigResumer`: - **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при `BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок. diff --git a/frontend/src/features/admin/audit/api.ts b/frontend/src/features/admin/audit/api.ts index 4c3cb12..87afd08 100644 --- a/frontend/src/features/admin/audit/api.ts +++ b/frontend/src/features/admin/audit/api.ts @@ -1,6 +1,16 @@ import { apiRequest } from '@/shared/api/client' -import type { AuditLogDto, PagedList } from '@/shared/api/types' +import type { AuditLogDto, AuditSource, PagedList } from '@/shared/api/types' -export function listAuditLogs(page: number, pageSize: number) { - return apiRequest>(`/admin/audit?page=${page}&pageSize=${pageSize}`) +export function listAuditLogs( + page: number, + pageSize: number, + source: AuditSource | undefined, + targetType: string | undefined, + action: string | undefined, +) { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) + if (source) params.set('source', source) + if (targetType) params.set('targetType', targetType) + if (action) params.set('action', action) + return apiRequest>(`/admin/audit?${params.toString()}`) } diff --git a/frontend/src/features/admin/billing/api.ts b/frontend/src/features/admin/billing/api.ts index 807e0db..cbc6a0e 100644 --- a/frontend/src/features/admin/billing/api.ts +++ b/frontend/src/features/admin/billing/api.ts @@ -3,6 +3,7 @@ import type { AdminPaymentRequestDto, BillingSettingsDto, PagedList, + PaymentRequestKind, PaymentRequestStatus, } from '@/shared/api/types' @@ -21,9 +22,17 @@ export function updateBillingSettings( }) } -export function listPaymentRequests(status?: PaymentRequestStatus, page = 1, pageSize = 20) { +export function listPaymentRequests( + status?: PaymentRequestStatus, + kind?: PaymentRequestKind, + search?: string, + page = 1, + pageSize = 20, +) { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) if (status) params.set('status', status) + if (kind) params.set('kind', kind) + if (search) params.set('search', search) return apiRequest>(`/admin/billing/requests?${params.toString()}`) } diff --git a/frontend/src/features/admin/configs/api.ts b/frontend/src/features/admin/configs/api.ts index 129d0be..1070a48 100644 --- a/frontend/src/features/admin/configs/api.ts +++ b/frontend/src/features/admin/configs/api.ts @@ -1,9 +1,18 @@ import { apiRequest } from '@/shared/api/client' -import type { AdminVpnConfigDto, ConfigStatus, PagedList } from '@/shared/api/types' +import type { AdminVpnConfigDto, ConfigStatus, PagedList, VpnProtocol } from '@/shared/api/types' -export function listAllConfigs(page: number, pageSize: number, search: string | undefined, status: ConfigStatus | undefined) { +export function listAllConfigs( + page: number, + pageSize: number, + search: string | undefined, + status: ConfigStatus | undefined, + protocol: VpnProtocol | undefined, + nodeId: string | undefined, +) { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) if (search) params.set('search', search) if (status) params.set('status', status) + if (protocol) params.set('protocol', protocol) + if (nodeId) params.set('nodeId', nodeId) return apiRequest>(`/admin/configs?${params.toString()}`) } diff --git a/frontend/src/features/admin/inbounds/api.ts b/frontend/src/features/admin/inbounds/api.ts index 267924d..fa7f863 100644 --- a/frontend/src/features/admin/inbounds/api.ts +++ b/frontend/src/features/admin/inbounds/api.ts @@ -16,3 +16,7 @@ export function publishInbound( body: { isPublished, displayName: displayName ?? null, allowedRoleIds }, }) } + +export function deleteInbound(id: string) { + return apiRequest(`/admin/inbounds/${id}`, { method: 'DELETE' }) +} diff --git a/frontend/src/features/admin/nodes/NodeCard.tsx b/frontend/src/features/admin/nodes/NodeCard.tsx index 1c61755..0b3068c 100644 --- a/frontend/src/features/admin/nodes/NodeCard.tsx +++ b/frontend/src/features/admin/nodes/NodeCard.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Button } from '@/shared/ui/button' import { Badge } from '@/shared/ui/badge' import { HttpError } from '@/shared/api/client' -import { listInbounds } from '@/features/admin/inbounds/api' +import { deleteInbound, listInbounds } from '@/features/admin/inbounds/api' import { PublishInboundDialog } from '@/features/admin/inbounds/PublishInboundDialog' import type { InboundDto, NodeDto, NodeStatus } from '@/shared/api/types' import { deleteNode, probeNode, syncNode } from './api' @@ -66,6 +66,15 @@ export function NodeCard({ node }: { node: NodeDto }) { onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), }) + const deleteInboundMutation = useMutation({ + mutationFn: (inboundId: string) => deleteInbound(inboundId), + onSuccess: async () => { + toast.success(t('admin.nodes.inboundDeleted')) + await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', node.id] }) + }, + onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), + }) + return ( @@ -123,10 +132,21 @@ export function NodeCard({ node }: { node: NodeDto }) { : t('admin.nodes.unpublished') : t('admin.nodes.unavailable')} - {inbound.isAvailable && ( + {inbound.isAvailable ? ( + ) : ( + )} diff --git a/frontend/src/features/admin/users/api.ts b/frontend/src/features/admin/users/api.ts index 88dc418..17b255b 100644 --- a/frontend/src/features/admin/users/api.ts +++ b/frontend/src/features/admin/users/api.ts @@ -1,9 +1,21 @@ import { apiRequest } from '@/shared/api/client' import type { PagedList, UserSummaryDto, VpnConfigDto } from '@/shared/api/types' -export function listUsers(page: number, pageSize: number, search: string | undefined) { +export function listUsers( + page: number, + pageSize: number, + search: string | undefined, + roleId: string | undefined, + isActivated: boolean | undefined, + isBlocked: boolean | undefined, + billingExpired: boolean | undefined, +) { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) if (search) params.set('search', search) + if (roleId) params.set('roleId', roleId) + if (isActivated !== undefined) params.set('isActivated', String(isActivated)) + if (isBlocked !== undefined) params.set('isBlocked', String(isBlocked)) + if (billingExpired !== undefined) params.set('billingExpired', String(billingExpired)) return apiRequest>(`/admin/users?${params.toString()}`) } diff --git a/frontend/src/routes/admin/activation.tsx b/frontend/src/routes/admin/activation.tsx index d59ade5..0f98704 100644 --- a/frontend/src/routes/admin/activation.tsx +++ b/frontend/src/routes/admin/activation.tsx @@ -5,18 +5,31 @@ import { useTranslation } from 'react-i18next' import { toast } from '@/shared/ui/toast-store' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Button } from '@/shared/ui/button' +import { Badge } from '@/shared/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { approveActivationRequest, listActivationRequests, rejectActivationRequest } from '@/features/admin/activation/api' +import type { ActivationStatus } from '@/shared/api/types' export const Route = createFileRoute('/admin/activation')({ component: AdminActivationPage }) +const STATUSES: ActivationStatus[] = ['Pending', 'Approved', 'Rejected'] +const STATUS_VARIANT: Record = { + Pending: 'warning', + Approved: 'success', + Rejected: 'destructive', +} + function AdminActivationPage() { const { t } = useTranslation() const queryClient = useQueryClient() const [page, setPage] = useState(1) + const [status, setStatus] = useState('Pending') + + const statusFilter = status === 'all' ? undefined : status const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['admin-activation-requests', page], - queryFn: () => listActivationRequests('Pending', page, 20), + queryKey: ['admin-activation-requests', page, statusFilter], + queryFn: () => listActivationRequests(statusFilter, page, 20), }) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-activation-requests'] }) @@ -39,55 +52,80 @@ function AdminActivationPage() { onError: () => toast.error(t('auth.genericError')), }) - if (isLoading) return

- - if (isError || !data) { - return ( -
-

{t('auth.genericError')}

- -
- ) - } - - if (data.items.length === 0) { - return

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

- } - return (
- {data.items.map((request) => ( - - - {request.userName} - {request.comment &&

{request.comment}

} -
- - - - -
- ))} + -
- - -
+ {isLoading &&

} + + {isError && ( +
+

{t('auth.genericError')}

+ +
+ )} + + {data?.items.length === 0 &&

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

} + + {data && data.items.length > 0 && ( + <> + {data.items.map((request) => ( + + + {request.userName} + {t(`admin.activation.status.${request.status}`)} + + + {request.comment &&

{request.comment}

} + {request.status === 'Pending' && ( +
+ + +
+ )} +
+
+ ))} + +
+ + +
+ + )}
) } diff --git a/frontend/src/routes/admin/audit.tsx b/frontend/src/routes/admin/audit.tsx index 2fe52af..6a872e7 100644 --- a/frontend/src/routes/admin/audit.tsx +++ b/frontend/src/routes/admin/audit.tsx @@ -4,23 +4,85 @@ import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { Badge } from '@/shared/ui/badge' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { listAuditLogs } from '@/features/admin/audit/api' +import type { AuditSource } from '@/shared/api/types' export const Route = createFileRoute('/admin/audit')({ component: AdminAuditPage }) const PAGE_SIZE = 50 +const SOURCES: AuditSource[] = ['Web', 'Telegram', 'System'] +const TARGET_TYPES = ['User', 'VpnConfig', 'Inbound', 'Node', 'SupportTicket', 'PaymentRequest'] + function AdminAuditPage() { const { t } = useTranslation() const [page, setPage] = useState(1) + const [source, setSource] = useState('all') + const [targetType, setTargetType] = useState('all') + const [action, setAction] = useState('') + + const sourceFilter = source === 'all' ? undefined : source + const targetTypeFilter = targetType === 'all' ? undefined : targetType const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['admin-audit', page], - queryFn: () => listAuditLogs(page, PAGE_SIZE), + queryKey: ['admin-audit', page, sourceFilter, targetTypeFilter, action], + queryFn: () => listAuditLogs(page, PAGE_SIZE, sourceFilter, targetTypeFilter, action || undefined), }) return (
+
+ { + setAction(e.target.value) + setPage(1) + }} + className="max-w-xs" + /> + + +
+ {isLoading &&

} {isError && ( diff --git a/frontend/src/routes/admin/billing.tsx b/frontend/src/routes/admin/billing.tsx index 4a49f40..1932be2 100644 --- a/frontend/src/routes/admin/billing.tsx +++ b/frontend/src/routes/admin/billing.tsx @@ -8,7 +8,8 @@ import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { HttpError } from '@/shared/api/client' -import type { PaymentRequestStatus } from '@/shared/api/types' +import { Input } from '@/shared/ui/input' +import type { PaymentRequestKind, PaymentRequestStatus } from '@/shared/api/types' import { BillingSettingsEditor } from '@/features/admin/billing/BillingSettingsEditor' import { confirmPaymentRequest, @@ -19,6 +20,8 @@ import { export const Route = createFileRoute('/admin/billing')({ component: AdminBillingPage }) +const KIND_FILTERS: (PaymentRequestKind | 'All')[] = ['Subscription', 'RoleChangeTopUp', 'All'] + const STATUS_FILTERS: (PaymentRequestStatus | 'All')[] = [ 'AwaitingConfirmation', 'AwaitingPayment', @@ -81,11 +84,16 @@ function RequestsSection() { const { t } = useTranslation() const queryClient = useQueryClient() const [status, setStatus] = useState('AwaitingConfirmation') + const [kind, setKind] = useState('All') + const [search, setSearch] = useState('') const [page, setPage] = useState(1) + const kindFilter = kind === 'All' ? undefined : kind + const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['admin-payment-requests', status, page], - queryFn: () => listPaymentRequests(status === 'All' ? undefined : status, page, 20), + queryKey: ['admin-payment-requests', status, kindFilter, search, page], + queryFn: () => + listPaymentRequests(status === 'All' ? undefined : status, kindFilter, search || undefined, page, 20), }) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-payment-requests'] }) @@ -113,24 +121,53 @@ function RequestsSection() { return (
- +
+ { + setSearch(e.target.value) + setPage(1) + }} + className="max-w-xs" + /> + + +
{isLoading &&

} diff --git a/frontend/src/routes/admin/configs.tsx b/frontend/src/routes/admin/configs.tsx index d9ba1de..9e5a871 100644 --- a/frontend/src/routes/admin/configs.tsx +++ b/frontend/src/routes/admin/configs.tsx @@ -11,7 +11,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ 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' +import { listNodes } from '@/features/admin/nodes/api' +import type { ConfigStatus, VpnProtocol } from '@/shared/api/types' + +const PROTOCOLS: VpnProtocol[] = ['Vless', 'Vmess', 'Trojan', 'Shadowsocks'] export const Route = createFileRoute('/admin/configs')({ component: AdminConfigsPage }) @@ -30,13 +33,19 @@ function AdminConfigsPage() { const queryClient = useQueryClient() const [search, setSearch] = useState('') const [status, setStatus] = useState('all') + const [protocol, setProtocol] = useState('all') + const [nodeId, setNodeId] = useState('all') const [page, setPage] = useState(1) const statusFilter = status === 'all' ? undefined : status + const protocolFilter = protocol === 'all' ? undefined : protocol + const nodeFilter = nodeId === 'all' ? undefined : nodeId + + const nodesQuery = useQuery({ queryKey: ['admin-nodes-lite'], queryFn: listNodes }) const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['admin-configs', page, search, statusFilter], - queryFn: () => listAllConfigs(page, PAGE_SIZE, search || undefined, statusFilter), + queryKey: ['admin-configs', page, search, statusFilter, protocolFilter, nodeFilter], + queryFn: () => listAllConfigs(page, PAGE_SIZE, search || undefined, statusFilter, protocolFilter, nodeFilter), }) const revokeMutation = useMutation({ @@ -79,6 +88,44 @@ function AdminConfigsPage() { ))} + +
{isLoading &&

} diff --git a/frontend/src/routes/admin/support.tsx b/frontend/src/routes/admin/support.tsx index 89c9905..e9bc623 100644 --- a/frontend/src/routes/admin/support.tsx +++ b/frontend/src/routes/admin/support.tsx @@ -4,9 +4,14 @@ import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { TicketStatusBadge } from '@/features/support/TicketStatusBadge' import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDetailDialog' import { listAllTickets } from '@/features/admin/support/api' +import type { TicketStatus, TicketType } from '@/shared/api/types' + +const TYPES: TicketType[] = ['BugReport', 'RoleRequest', 'ExtensionRequest'] +const STATUSES: TicketStatus[] = ['Open', 'Resolved', 'Closed'] export const Route = createFileRoute('/admin/support')({ component: AdminSupportPage, @@ -24,14 +29,60 @@ function AdminSupportPage() { const navigate = useNavigate({ from: Route.fullPath }) const { ticket: selectedTicketId } = Route.useSearch() const [page, setPage] = useState(1) + const [type, setType] = useState('all') + const [status, setStatus] = useState('all') + + const typeFilter = type === 'all' ? undefined : type + const statusFilter = status === 'all' ? undefined : status const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['admin-tickets', page], - queryFn: () => listAllTickets(undefined, undefined, page, PAGE_SIZE), + queryKey: ['admin-tickets', page, typeFilter, statusFilter], + queryFn: () => listAllTickets(typeFilter, statusFilter, page, PAGE_SIZE), }) return (
+
+ + +
+ {isLoading &&

} {isError && ( diff --git a/frontend/src/routes/admin/users.tsx b/frontend/src/routes/admin/users.tsx index e27955c..0f85910 100644 --- a/frontend/src/routes/admin/users.tsx +++ b/frontend/src/routes/admin/users.tsx @@ -5,17 +5,25 @@ import { useTranslation } from 'react-i18next' 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 { PaidUntilBadge } from '@/features/billing/PaidUntilBadge' import { listUsers } from '@/features/admin/users/api' +import { listRoles } from '@/features/admin/roles/api' import { UserManageDialog } from '@/features/admin/users/UserManageDialog' export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage }) const PAGE_SIZE = 20 +type StatusFilter = 'all' | 'active' | 'pending' | 'blocked' +type BillingFilter = 'all' | 'expired' | 'paid' + function AdminUsersPage() { const { t } = useTranslation() const [search, setSearch] = useState('') + const [roleId, setRoleId] = useState('all') + const [status, setStatus] = useState('all') + const [billing, setBilling] = useState('all') const [page, setPage] = useState(1) // Id, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит // изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп @@ -23,23 +31,84 @@ function AdminUsersPage() { // запроса на каждый рендер. const [managingId, setManagingId] = useState(null) + const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles }) + + const roleFilter = roleId === 'all' ? undefined : roleId + const isActivated = status === 'active' ? true : status === 'pending' ? false : undefined + const isBlocked = status === 'blocked' ? true : status === 'all' ? undefined : false + const billingExpired = billing === 'all' ? undefined : billing === 'expired' + const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['admin-users', page, search], - queryFn: () => listUsers(page, PAGE_SIZE, search || undefined), + queryKey: ['admin-users', page, search, roleFilter, isActivated, isBlocked, billingExpired], + queryFn: () => listUsers(page, PAGE_SIZE, search || undefined, roleFilter, isActivated, isBlocked, billingExpired), }) const managingUser = managingId ? (data?.items.find((u) => u.id === managingId) ?? null) : null return (
- { - setSearch(e.target.value) - setPage(1) - }} - className="max-w-sm" - /> +
+ { + setSearch(e.target.value) + setPage(1) + }} + className="max-w-sm" + /> + + + +
{isLoading &&

} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index d75cd6b..cf317d8 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -149,6 +149,7 @@ const resources = { awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.', roleChangeTopUp: 'Доплата за смену роли', roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.', + subscription: 'Подписка', }, instructions: { @@ -272,6 +273,11 @@ const resources = { manage: 'Управление', empty: 'Пользователи не найдены.', total: 'Всего: {{count}}', + allRoles: 'Все роли', + allStatuses: 'Все статусы', + allBilling: 'Любая оплата', + billingExpired: 'Просрочена', + billingPaid: 'Оплачена', status: { blocked: 'Заблокирован', active: 'Активен', @@ -303,6 +309,8 @@ const resources = { traffic: 'Трафик', statusLabel: 'Статус', allStatuses: 'Все статусы', + allProtocols: 'Все протоколы', + allNodes: 'Все ноды', created: 'Создан', empty: 'Конфиги не найдены.', total: 'Всего: {{count}}', @@ -313,6 +321,12 @@ const resources = { rejected: 'Запрос отклонён.', approve: 'Активировать', reject: 'Отклонить', + allStatuses: 'Все статусы', + status: { + Pending: 'Ожидает', + Approved: 'Одобрен', + Rejected: 'Отклонён', + }, }, roles: { create: 'Создать роль', @@ -368,7 +382,9 @@ const resources = { defaultBillingEnabledForNewRolesLabel: 'Новые роли по умолчанию с включённым биллингом', settingsUpdated: 'Настройки биллинга обновлены.', requestsTitle: 'Заявки на оплату', + searchPlaceholder: 'Поиск по имени пользователя', allStatuses: 'Все статусы', + allKinds: 'Все виды', user: 'Пользователь', period: 'Период', amount: 'Сумма', @@ -420,6 +436,8 @@ const resources = { published: 'Опубликован', unpublished: 'Не опубликован', unavailable: 'Недоступен на панели', + inboundDeleted: 'Инбаунд удалён.', + confirmDeleteInbound: 'Удалить инбаунд? Все ещё активные конфиги на нём будут отозваны.', publishSaved: 'Настройки публикации сохранены.', displayName: 'Отображаемое имя', allowedRoles: 'Доступно ролям', @@ -478,6 +496,8 @@ const resources = { approved: 'Заявка одобрена, роль выдана.', reject: 'Отклонить', rejected: 'Заявка отклонена.', + allTypes: 'Все типы', + allStatuses: 'Все статусы', }, audit: { time: 'Время', @@ -485,6 +505,9 @@ const resources = { target: 'Объект', source: 'Источник', empty: 'Журнал аудита пуст.', + actionPlaceholder: 'Поиск по действию', + allSources: 'Все источники', + allTargetTypes: 'Все типы объектов', }, maintenance: { closedTickets: { @@ -694,6 +717,7 @@ const resources = { awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.', roleChangeTopUp: 'Role change top-up', roleChangeTopUpHint: "Your new role costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.", + subscription: 'Subscription', }, instructions: { @@ -817,6 +841,11 @@ const resources = { manage: 'Manage', empty: 'No users found.', total: 'Total: {{count}}', + allRoles: 'All roles', + allStatuses: 'All statuses', + allBilling: 'Any billing', + billingExpired: 'Expired', + billingPaid: 'Paid', status: { blocked: 'Blocked', active: 'Active', @@ -848,6 +877,8 @@ const resources = { traffic: 'Traffic', statusLabel: 'Status', allStatuses: 'All statuses', + allProtocols: 'All protocols', + allNodes: 'All nodes', created: 'Created', empty: 'No configs found.', total: 'Total: {{count}}', @@ -858,6 +889,12 @@ const resources = { rejected: 'Request rejected.', approve: 'Approve', reject: 'Reject', + allStatuses: 'All statuses', + status: { + Pending: 'Pending', + Approved: 'Approved', + Rejected: 'Rejected', + }, }, roles: { create: 'Create role', @@ -913,7 +950,9 @@ const resources = { defaultBillingEnabledForNewRolesLabel: 'New roles default to billing enabled', settingsUpdated: 'Billing settings updated.', requestsTitle: 'Payment requests', + searchPlaceholder: 'Search by username', allStatuses: 'All statuses', + allKinds: 'All kinds', user: 'User', period: 'Period', amount: 'Amount', @@ -965,6 +1004,8 @@ const resources = { published: 'Published', unpublished: 'Not published', unavailable: 'Unavailable on panel', + inboundDeleted: 'Inbound deleted.', + confirmDeleteInbound: 'Delete this inbound? Any still-active configs on it will be revoked.', publishSaved: 'Publishing settings saved.', displayName: 'Display name', allowedRoles: 'Allowed for roles', @@ -1023,6 +1064,8 @@ const resources = { approved: 'Request approved, role granted.', reject: 'Reject', rejected: 'Request rejected.', + allTypes: 'All types', + allStatuses: 'All statuses', }, audit: { time: 'Time', @@ -1030,6 +1073,9 @@ const resources = { target: 'Target', source: 'Source', empty: 'The audit log is empty.', + actionPlaceholder: 'Search by action', + allSources: 'All sources', + allTargetTypes: 'All target types', }, maintenance: { closedTickets: {