Enhance admin endpoints and queries for improved filtering and management
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- Updated `ListPaymentRequestsQuery` to include `Kind` and `Search` parameters for better filtering of payment requests.
- Enhanced `ListAuditLogsQuery` to support additional filters: `Source`, `TargetType`, and `Action`, improving audit log retrieval.
- Modified `ListUsersQuery` to accept new filters: `RoleId`, `IsActivated`, `IsBlocked`, and `BillingExpired`, allowing for more granular user management.
- Introduced `DeleteInbound` endpoint to allow deletion of inbounds that are not currently available, enhancing inbound management capabilities.
- Updated frontend API calls to reflect new query parameters and support for additional filtering options in the admin interface.
- Revised API documentation to include new parameters and endpoint functionalities for better clarity and usage guidance.
This commit is contained in:
Leonid Pershin
2026-07-20 10:35:48 +03:00
parent e19860ba46
commit 33ad98cf62
37 changed files with 1115 additions and 124 deletions
@@ -53,7 +53,13 @@ public static class AdminBillingEndpoints
CancellationToken cancellationToken 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); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -98,6 +104,8 @@ public static class AdminBillingEndpoints
public sealed record ListPaymentRequestsRequest( public sealed record ListPaymentRequestsRequest(
PaymentRequestStatus? Status, PaymentRequestStatus? Status,
PaymentRequestKind? Kind,
string? Search,
int Page = 1, int Page = 1,
int PageSize = 20 int PageSize = 20
); );
@@ -3,6 +3,7 @@ using PnvPanel.Application.Admin.Audit;
using PnvPanel.Application.Admin.Stats; using PnvPanel.Application.Admin.Stats;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -30,11 +31,20 @@ public static class AdminStatsEndpoints
private static async Task<IResult> GetAudit( private static async Task<IResult> GetAudit(
int page, int page,
int pageSize, int pageSize,
AuditSource? source,
string? targetType,
string? action,
ISender sender, ISender sender,
CancellationToken cancellationToken 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); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -6,6 +6,7 @@ using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Configs; using PnvPanel.Application.Configs;
using PnvPanel.Domain.Configs; using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -44,11 +45,23 @@ public static class AdminUserEndpoints
int page, int page,
int pageSize, int pageSize,
string? search, string? search,
Guid? roleId,
bool? isActivated,
bool? isBlocked,
bool? billingExpired,
ISender sender, ISender sender,
CancellationToken cancellationToken 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); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -112,6 +125,8 @@ public static class AdminUserEndpoints
int pageSize, int pageSize,
string? search, string? search,
ConfigStatus? status, ConfigStatus? status,
VpnProtocol? protocol,
Guid? nodeId,
ISender sender, ISender sender,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
@@ -120,7 +135,9 @@ public static class AdminUserEndpoints
page <= 0 ? 1 : page, page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize, pageSize <= 0 ? 20 : pageSize,
search, search,
status status,
protocol,
nodeId
); );
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -15,6 +15,7 @@ public static class InboundEndpoints
admin.MapGet("", ListInbounds).Produces<IReadOnlyList<InboundDto>>(); admin.MapGet("", ListInbounds).Produces<IReadOnlyList<InboundDto>>();
admin.MapPut("/{id:guid}/publish", PublishInbound).Produces<InboundDto>(); admin.MapPut("/{id:guid}/publish", PublishInbound).Produces<InboundDto>();
admin.MapDelete("/{id:guid}", DeleteInbound);
return app; return app;
} }
@@ -45,6 +46,16 @@ public static class InboundEndpoints
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteInbound(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteInboundCommand(id), cancellationToken);
return result.ToHttpResult();
}
} }
public sealed record PublishInboundBody( public sealed record PublishInboundBody(
@@ -4,8 +4,13 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Audit; namespace PnvPanel.Application.Admin.Audit;
public sealed record ListAuditLogsQuery(int Page, int PageSize) public sealed record ListAuditLogsQuery(
: IQuery<Result<PagedList<AuditLogDto>>>; int Page,
int PageSize,
AuditSource? Source,
string? TargetType,
string? Action
) : IQuery<Result<PagedList<AuditLogDto>>>;
public sealed record AuditLogDto( public sealed record AuditLogDto(
long Id, long Id,
@@ -16,8 +16,16 @@ public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
var result = await dbContext var logsQuery = dbContext.AuditLogs.AsNoTracking();
.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) .OrderByDescending(a => a.CreatedAt)
.Select(a => new AuditLogDto( .Select(a => new AuditLogDto(
a.Id, a.Id,
@@ -4,5 +4,10 @@ using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing; namespace PnvPanel.Application.Admin.Billing;
public sealed record ListPaymentRequestsQuery(PaymentRequestStatus? StatusFilter, int Page, int PageSize) public sealed record ListPaymentRequestsQuery(
: IQuery<Result<PagedList<AdminPaymentRequestDto>>>; PaymentRequestStatus? StatusFilter,
PaymentRequestKind? KindFilter,
string? Search,
int Page,
int PageSize
) : IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
@@ -21,6 +21,18 @@ public sealed class ListPaymentRequestsQueryHandler(
var requestsQuery = dbContext.PaymentRequests.AsNoTracking(); var requestsQuery = dbContext.PaymentRequests.AsNoTracking();
if (query.StatusFilter is { } status) if (query.StatusFilter is { } status)
requestsQuery = requestsQuery.Where(r => r.Status == 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 var page1 = await requestsQuery
.OrderByDescending(r => r.CreatedAt) .OrderByDescending(r => r.CreatedAt)
@@ -2,14 +2,18 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs; using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Admin.Configs; namespace PnvPanel.Application.Admin.Configs;
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет /// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary> /// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).
/// <paramref name="NodeId"/> фильтрует по ноде инбаунда конфига (JOIN Inbounds).</summary>
public sealed record ListAllConfigsQuery( public sealed record ListAllConfigsQuery(
int Page, int Page,
int PageSize, int PageSize,
string? Search, string? Search,
ConfigStatus? Status ConfigStatus? Status,
VpnProtocol? Protocol,
Guid? NodeId
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>; ) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
@@ -23,6 +23,19 @@ public sealed class ListAllConfigsQueryHandler(
if (query.Status is { } status) if (query.Status is { } status)
configsQuery = configsQuery.Where(c => c.Status == 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)) if (!string.IsNullOrWhiteSpace(query.Search))
{ {
var search = query.Search.Trim(); var search = query.Search.Trim();
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
/// <summary>Force-удаление недоступного на панели инбаунда вместе с каскадным отзывом ещё живых
/// конфигов на нём — см. DeleteInboundCommandHandler.</summary>
public sealed record DeleteInboundCommand(Guid InboundId) : ICommand<Result>;
@@ -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;
/// <summary>Удаляет инбаунд, которого больше нет на панели (см. SyncNodeCommandHandler —
/// автоматически он не удаляется, пока по нему есть VpnConfig, чтобы не потерять ссылку на историю).
/// Разрешено только для `IsAvailable == false` — живой, всё ещё синхронизируемый инбаунд через этот
/// путь не удалить. Перед удалением каскадно отзывает все ещё не-Revoked конфиги на нём: панельный
/// клиент всё равно недостижим (инбаунда для него на 3x-ui уже нет), поэтому Revoke — чисто локальная
/// операция, без вызова гейтвея (см. RevokeVpnConfigCommandHandler для того же паттерна). Уже
/// Revoked-конфиги при этом остаются в БД с InboundId, указывающим на удалённую запись — это
/// осознанный компромисс (см. domain-model.md): нигде в пользовательских списках Revoked-конфиги не
/// показываются, а в админском списке отсутствующий инбаунд отображается как "?".</summary>
public sealed class DeleteInboundCommandHandler(
IAppDbContext dbContext,
IRealtimeNotifier notifier,
ICurrentUser currentUser
) : ICommandHandler<DeleteInboundCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -8,4 +8,9 @@ public static class InboundErrors
"Inbounds.NotFound", "Inbounds.NotFound",
"Inbound не найден." "Inbound не найден."
); );
public static readonly Error StillAvailable = Error.Validation(
"Inbounds.StillAvailable",
"Inbound всё ещё существует на панели — удалить можно только недоступные (IsAvailable=false)."
);
} }
@@ -4,5 +4,12 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Users; namespace PnvPanel.Application.Admin.Users;
public sealed record ListUsersQuery(int Page, int PageSize, string? Search) public sealed record ListUsersQuery(
: IQuery<Result<PagedList<UserSummaryDto>>>; int Page,
int PageSize,
string? Search,
Guid? RoleId,
bool? IsActivated,
bool? IsBlocked,
bool? BillingExpired
) : IQuery<Result<PagedList<UserSummaryDto>>>;
@@ -21,6 +21,10 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService, IApp
page, page,
pageSize, pageSize,
query.Search, query.Search,
query.RoleId,
query.IsActivated,
query.IsBlocked,
query.BillingExpired,
cancellationToken cancellationToken
); );
@@ -92,6 +92,14 @@ public interface IIdentityService
CancellationToken cancellationToken CancellationToken cancellationToken
); );
/// <summary>Для фильтрации списков по владельцу там, где сущность хранит только UserId (см.
/// ListPaymentRequestsQueryHandler) — резолвится ДО пагинации, а не после (в отличие от
/// GetUserNamesAsync, который просто подставляет имена в уже отобранную страницу).</summary>
Task<IReadOnlyList<Guid>> FindUserIdsByUserNameAsync(
string search,
CancellationToken cancellationToken
);
/// <summary>Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной.</summary> /// <summary>Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной.</summary>
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken); Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
@@ -117,6 +125,10 @@ public interface IIdentityService
int page, int page,
int pageSize, int pageSize,
string? search, string? search,
Guid? roleId,
bool? isActivated,
bool? isBlocked,
bool? billingExpired,
CancellationToken cancellationToken CancellationToken cancellationToken
); );
@@ -4,13 +4,15 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.Identity; namespace PnvPanel.Infrastructure.Identity;
internal sealed class IdentityService( internal sealed class IdentityService(
UserManager<AppUser> userManager, UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager, SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager RoleManager<AppRole> roleManager,
AppDbContext dbContext
) : IIdentityService ) : IIdentityService
{ {
public async Task<Result<Guid>> CreateUserAsync( public async Task<Result<Guid>> CreateUserAsync(
@@ -176,6 +178,17 @@ internal sealed class IdentityService(
.ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken); .ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken);
} }
public async Task<IReadOnlyList<Guid>> FindUserIdsByUserNameAsync(
string search,
CancellationToken cancellationToken
)
{
return await userManager
.Users.Where(u => u.UserName!.Contains(search))
.Select(u => u.Id)
.ToListAsync(cancellationToken);
}
public async Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken) public async Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
{ {
var user = await userManager.FindByIdAsync(userId.ToString()); var user = await userManager.FindByIdAsync(userId.ToString());
@@ -252,12 +265,36 @@ internal sealed class IdentityService(
int page, int page,
int pageSize, int pageSize,
string? search, string? search,
Guid? roleId,
bool? isActivated,
bool? isBlocked,
bool? billingExpired,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var query = userManager.Users.AsNoTracking(); var query = userManager.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
query = query.Where(u => u.UserName!.Contains(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 total = await query.CountAsync(cancellationToken);
var users = await query var users = await query
@@ -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);
}
}
@@ -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<IIdentityService>();
[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<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [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<CancellationToken>())
.Returns([aliceId]);
_identityService
.GetUserNamesAsync(Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [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);
}
}
@@ -41,7 +41,7 @@ public class ListAllConfigsQueryHandlerTests
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle( 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 CancellationToken.None
); );
@@ -83,7 +83,7 @@ public class ListAllConfigsQueryHandlerTests
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle( 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 CancellationToken.None
); );
@@ -121,7 +121,7 @@ public class ListAllConfigsQueryHandlerTests
var handler = new ListAllConfigsQueryHandler(dbContext, _identityService); var handler = new ListAllConfigsQueryHandler(dbContext, _identityService);
var result = await handler.Handle( 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 CancellationToken.None
); );
@@ -129,4 +129,85 @@ public class ListAllConfigsQueryHandlerTests
var item = Assert.Single(result.Value.Items); var item = Assert.Single(result.Value.Items);
Assert.Equal(revoked.Id, item.Id); 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<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [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<IReadOnlyCollection<Guid>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<Guid, string> { [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);
}
} }
@@ -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<IRealtimeNotifier>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
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<CancellationToken>()
);
await _notifier
.DidNotReceive()
.NotifyConfigStatusChangedAsync(
userId,
alreadyRevoked.Id,
Arg.Any<ConfigStatus>(),
Arg.Any<CancellationToken>()
);
}
}
@@ -26,12 +26,12 @@ public class ListUsersQueryHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService _identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>()) .ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20)); .Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext); 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.IsSuccess);
Assert.True(result.Value.Items.Single().BillingPendingReview); Assert.True(result.Value.Items.Single().BillingPendingReview);
@@ -44,12 +44,12 @@ public class ListUsersQueryHandlerTests
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
_identityService _identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>()) .ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20)); .Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext); 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.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview); Assert.False(result.Value.Items.Single().BillingPendingReview);
@@ -68,12 +68,12 @@ public class ListUsersQueryHandlerTests
await dbContext.SaveChangesAsync(CancellationToken.None); await dbContext.SaveChangesAsync(CancellationToken.None);
_identityService _identityService
.ListUsersAsync(1, 20, null, Arg.Any<CancellationToken>()) .ListUsersAsync(1, 20, null, null, null, null, null, Arg.Any<CancellationToken>())
.Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20)); .Returns(new PagedList<UserSummaryDto>([Summary(userId)], 1, 1, 20));
var handler = new ListUsersQueryHandler(_identityService, dbContext); 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.IsSuccess);
Assert.False(result.Value.Items.Single().BillingPendingReview); Assert.False(result.Value.Items.Single().BillingPendingReview);
+5 -4
View File
@@ -278,7 +278,7 @@ approve/reject над `ActivationRequest`.
| ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- | | ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- |
| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | | GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` |
| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` | | PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` |
| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList<AdminPaymentRequestDto>` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) | | GET | `/api/admin/billing/requests` | admin | query: `status?, kind?, search?, page=1, pageSize=20` (`search` — по имени пользователя, резолвится до пагинации) | `PagedList<AdminPaymentRequestDto>` (включает `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}/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/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) | | 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[]` | | GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` |
| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds? }` | `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 ## Admin — Users & Stats
| Метод | Путь | Роль | Тело запроса | Тело ответа | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ---------------------------------------- | ----- | --------------------------- | ------------- | | ------ | ---------------------------------------- | ----- | --------------------------- | ------------- |
| GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `PagedList<UserSummaryDto>` | | GET | `/api/admin/users` | admin | query: `page, pageSize, search?, roleId?, isActivated?, isBlocked?, billingExpired?` | `PagedList<UserSummaryDto>` |
| PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` | | PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` |
| PATCH | `/api/admin/users/{id}/unblock` | 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` | | POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` |
| DELETE | `/api/admin/users/{id}` | admin | — | `204 No Content` (отзывает все конфиги пользователя в 3x-ui, затем удаляет учётку; себя удалить нельзя) | | DELETE | `/api/admin/users/{id}` | admin | — | `204 No Content` (отзывает все конфиги пользователя в 3x-ui, затем удаляет учётку; себя удалить нельзя) |
| GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` | | GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` |
| GET | `/api/admin/configs` | admin | query: `page, pageSize, search?, status?` | `PagedList<AdminVpnConfigDto>` | | GET | `/api/admin/configs` | admin | query: `page, pageSize, search?, status?, protocol?, nodeId?` | `PagedList<AdminVpnConfigDto>` |
| DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) | | DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) |
| GET | `/api/admin/stats` | admin | — | `StatsDto` | | GET | `/api/admin/stats` | admin | — | `StatsDto` |
| GET | `/api/admin/audit` | admin | query: `page, pageSize` | `PagedList<AuditLogDto>` | | GET | `/api/admin/audit` | admin | query: `page, pageSize, source?, targetType?, action?` (`action` — подстрока) | `PagedList<AuditLogDto>` |
`AdminVpnConfigDto` — глобальный список конфигов для админа (не скоупится одним пользователем, в `AdminVpnConfigDto` — глобальный список конфигов для админа (не скоупится одним пользователем, в
отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName, отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName,
+18
View File
@@ -88,6 +88,17 @@ AppUser
`IsAvailable` сбрасывается обратно в `true`: без этого разово пропавший инбаунд оставался бы `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`) — это диагностический > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический
> индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать > индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать
> `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов > `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов
@@ -577,6 +588,13 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
знает про `PaymentRequest` (граница Identity/биллинг), поэтому джойн с `PaymentRequests` сделан в знает про `PaymentRequest` (граница Identity/биллинг), поэтому джойн с `PaymentRequests` сделан в
Application-хендлере поверх результата `IIdentityService.ListUsersAsync`, а не внутри Identity. 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`: Прочие точки, не входящие в `BillingConfigResumer`:
- **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при - **Создание** (`CreateVpnConfigCommandHandler`) пушит `expiresAt = profile.BillingPaidUntil` при
`BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок. `BillingEnabled` через `AddClientAsync` — свежий конфиг сразу несёт правильный срок.
+13 -3
View File
@@ -1,6 +1,16 @@
import { apiRequest } from '@/shared/api/client' 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) { export function listAuditLogs(
return apiRequest<PagedList<AuditLogDto>>(`/admin/audit?page=${page}&pageSize=${pageSize}`) 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<PagedList<AuditLogDto>>(`/admin/audit?${params.toString()}`)
} }
+10 -1
View File
@@ -3,6 +3,7 @@ import type {
AdminPaymentRequestDto, AdminPaymentRequestDto,
BillingSettingsDto, BillingSettingsDto,
PagedList, PagedList,
PaymentRequestKind,
PaymentRequestStatus, PaymentRequestStatus,
} from '@/shared/api/types' } 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) }) const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (status) params.set('status', status) if (status) params.set('status', status)
if (kind) params.set('kind', kind)
if (search) params.set('search', search)
return apiRequest<PagedList<AdminPaymentRequestDto>>(`/admin/billing/requests?${params.toString()}`) return apiRequest<PagedList<AdminPaymentRequestDto>>(`/admin/billing/requests?${params.toString()}`)
} }
+11 -2
View File
@@ -1,9 +1,18 @@
import { apiRequest } from '@/shared/api/client' 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) }) const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (search) params.set('search', search) if (search) params.set('search', search)
if (status) params.set('status', status) if (status) params.set('status', status)
if (protocol) params.set('protocol', protocol)
if (nodeId) params.set('nodeId', nodeId)
return apiRequest<PagedList<AdminVpnConfigDto>>(`/admin/configs?${params.toString()}`) return apiRequest<PagedList<AdminVpnConfigDto>>(`/admin/configs?${params.toString()}`)
} }
@@ -16,3 +16,7 @@ export function publishInbound(
body: { isPublished, displayName: displayName ?? null, allowedRoleIds }, body: { isPublished, displayName: displayName ?? null, allowedRoleIds },
}) })
} }
export function deleteInbound(id: string) {
return apiRequest<void>(`/admin/inbounds/${id}`, { method: 'DELETE' })
}
+22 -2
View File
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { HttpError } from '@/shared/api/client' 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 { PublishInboundDialog } from '@/features/admin/inbounds/PublishInboundDialog'
import type { InboundDto, NodeDto, NodeStatus } from '@/shared/api/types' import type { InboundDto, NodeDto, NodeStatus } from '@/shared/api/types'
import { deleteNode, probeNode, syncNode } from './api' 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')), 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 ( return (
<Card> <Card>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0"> <CardHeader className="flex-row items-start justify-between gap-2 space-y-0">
@@ -123,10 +132,21 @@ export function NodeCard({ node }: { node: NodeDto }) {
: t('admin.nodes.unpublished') : t('admin.nodes.unpublished')
: t('admin.nodes.unavailable')} : t('admin.nodes.unavailable')}
</Badge> </Badge>
{inbound.isAvailable && ( {inbound.isAvailable ? (
<Button size="sm" variant="outline" onClick={() => setPublishing(inbound)}> <Button size="sm" variant="outline" onClick={() => setPublishing(inbound)}>
{t('admin.nodes.publish')} {t('admin.nodes.publish')}
</Button> </Button>
) : (
<Button
size="sm"
variant="ghost"
disabled={deleteInboundMutation.isPending}
onClick={() => {
if (confirm(t('admin.nodes.confirmDeleteInbound'))) deleteInboundMutation.mutate(inbound.id)
}}
>
{t('admin.nodes.delete')}
</Button>
)} )}
</div> </div>
</div> </div>
+13 -1
View File
@@ -1,9 +1,21 @@
import { apiRequest } from '@/shared/api/client' import { apiRequest } from '@/shared/api/client'
import type { PagedList, UserSummaryDto, VpnConfigDto } from '@/shared/api/types' 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) }) const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (search) params.set('search', search) 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<PagedList<UserSummaryDto>>(`/admin/users?${params.toString()}`) return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${params.toString()}`)
} }
+86 -48
View File
@@ -5,18 +5,31 @@ import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button' 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 { approveActivationRequest, listActivationRequests, rejectActivationRequest } from '@/features/admin/activation/api'
import type { ActivationStatus } from '@/shared/api/types'
export const Route = createFileRoute('/admin/activation')({ component: AdminActivationPage }) export const Route = createFileRoute('/admin/activation')({ component: AdminActivationPage })
const STATUSES: ActivationStatus[] = ['Pending', 'Approved', 'Rejected']
const STATUS_VARIANT: Record<ActivationStatus, 'success' | 'warning' | 'destructive'> = {
Pending: 'warning',
Approved: 'success',
Rejected: 'destructive',
}
function AdminActivationPage() { function AdminActivationPage() {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [status, setStatus] = useState<ActivationStatus | 'all'>('Pending')
const statusFilter = status === 'all' ? undefined : status
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-activation-requests', page], queryKey: ['admin-activation-requests', page, statusFilter],
queryFn: () => listActivationRequests('Pending', page, 20), queryFn: () => listActivationRequests(statusFilter, page, 20),
}) })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-activation-requests'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-activation-requests'] })
@@ -39,55 +52,80 @@ function AdminActivationPage() {
onError: () => toast.error(t('auth.genericError')), onError: () => toast.error(t('auth.genericError')),
}) })
if (isLoading) return <p className="text-sm text-muted-foreground"></p>
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)
}
if (data.items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('admin.activation.empty')}</p>
}
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{data.items.map((request) => ( <Select
<Card key={request.id}> value={status}
<CardHeader> onValueChange={(v) => {
<CardTitle className="text-base">{request.userName}</CardTitle> setStatus(v as ActivationStatus | 'all')
{request.comment && <p className="text-sm text-muted-foreground">{request.comment}</p>} setPage(1)
</CardHeader> }}
<CardContent className="flex gap-2"> >
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate(request.id)}> <SelectTrigger className="w-48">
{t('admin.activation.approve')} <SelectValue />
</Button> </SelectTrigger>
<Button <SelectContent>
size="sm" <SelectItem value="all">{t('admin.activation.allStatuses')}</SelectItem>
variant="outline" {STATUSES.map((s) => (
disabled={rejectMutation.isPending} <SelectItem key={s} value={s}>
onClick={() => rejectMutation.mutate(request.id)} {t(`admin.activation.status.${s}`)}
> </SelectItem>
{t('admin.activation.reject')} ))}
</Button> </SelectContent>
</CardContent> </Select>
</Card>
))}
<div className="flex justify-end gap-2 text-sm"> {isLoading && <p className="text-sm text-muted-foreground"></p>}
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')} {isError && (
</Button> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page * 20 >= data.total} onClick={() => setPage((p) => p + 1)}> <p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
{t('admin.next')} <Button variant="outline" size="sm" onClick={() => void refetch()}>
</Button> {t('activation.retry')}
</div> </Button>
</div>
)}
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.activation.empty')}</p>}
{data && data.items.length > 0 && (
<>
{data.items.map((request) => (
<Card key={request.id}>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-base">{request.userName}</CardTitle>
<Badge variant={STATUS_VARIANT[request.status]}>{t(`admin.activation.status.${request.status}`)}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{request.comment && <p className="text-sm text-muted-foreground">{request.comment}</p>}
{request.status === 'Pending' && (
<div className="flex gap-2">
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate(request.id)}>
{t('admin.activation.approve')}
</Button>
<Button
size="sm"
variant="outline"
disabled={rejectMutation.isPending}
onClick={() => rejectMutation.mutate(request.id)}
>
{t('admin.activation.reject')}
</Button>
</div>
)}
</CardContent>
</Card>
))}
<div className="flex justify-end gap-2 text-sm">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * 20 >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
</>
)}
</div> </div>
) )
} }
+64 -2
View File
@@ -4,23 +4,85 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge' 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 { listAuditLogs } from '@/features/admin/audit/api'
import type { AuditSource } from '@/shared/api/types'
export const Route = createFileRoute('/admin/audit')({ component: AdminAuditPage }) export const Route = createFileRoute('/admin/audit')({ component: AdminAuditPage })
const PAGE_SIZE = 50 const PAGE_SIZE = 50
const SOURCES: AuditSource[] = ['Web', 'Telegram', 'System']
const TARGET_TYPES = ['User', 'VpnConfig', 'Inbound', 'Node', 'SupportTicket', 'PaymentRequest']
function AdminAuditPage() { function AdminAuditPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [source, setSource] = useState<AuditSource | 'all'>('all')
const [targetType, setTargetType] = useState<string>('all')
const [action, setAction] = useState('')
const sourceFilter = source === 'all' ? undefined : source
const targetTypeFilter = targetType === 'all' ? undefined : targetType
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-audit', page], queryKey: ['admin-audit', page, sourceFilter, targetTypeFilter, action],
queryFn: () => listAuditLogs(page, PAGE_SIZE), queryFn: () => listAuditLogs(page, PAGE_SIZE, sourceFilter, targetTypeFilter, action || undefined),
}) })
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Input
placeholder={t('admin.audit.actionPlaceholder')}
value={action}
onChange={(e) => {
setAction(e.target.value)
setPage(1)
}}
className="max-w-xs"
/>
<Select
value={source}
onValueChange={(v) => {
setSource(v as AuditSource | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.audit.allSources')}</SelectItem>
{SOURCES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={targetType}
onValueChange={(v) => {
setTargetType(v)
setPage(1)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.audit.allTargetTypes')}</SelectItem>
{TARGET_TYPES.map((tt) => (
<SelectItem key={tt} value={tt}>
{tt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>} {isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && ( {isError && (
+58 -21
View File
@@ -8,7 +8,8 @@ import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client' 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 { BillingSettingsEditor } from '@/features/admin/billing/BillingSettingsEditor'
import { import {
confirmPaymentRequest, confirmPaymentRequest,
@@ -19,6 +20,8 @@ import {
export const Route = createFileRoute('/admin/billing')({ component: AdminBillingPage }) export const Route = createFileRoute('/admin/billing')({ component: AdminBillingPage })
const KIND_FILTERS: (PaymentRequestKind | 'All')[] = ['Subscription', 'RoleChangeTopUp', 'All']
const STATUS_FILTERS: (PaymentRequestStatus | 'All')[] = [ const STATUS_FILTERS: (PaymentRequestStatus | 'All')[] = [
'AwaitingConfirmation', 'AwaitingConfirmation',
'AwaitingPayment', 'AwaitingPayment',
@@ -81,11 +84,16 @@ function RequestsSection() {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [status, setStatus] = useState<PaymentRequestStatus | 'All'>('AwaitingConfirmation') const [status, setStatus] = useState<PaymentRequestStatus | 'All'>('AwaitingConfirmation')
const [kind, setKind] = useState<PaymentRequestKind | 'All'>('All')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const kindFilter = kind === 'All' ? undefined : kind
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-payment-requests', status, page], queryKey: ['admin-payment-requests', status, kindFilter, search, page],
queryFn: () => listPaymentRequests(status === 'All' ? undefined : status, page, 20), queryFn: () =>
listPaymentRequests(status === 'All' ? undefined : status, kindFilter, search || undefined, page, 20),
}) })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-payment-requests'] }) const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-payment-requests'] })
@@ -113,24 +121,53 @@ function RequestsSection() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Select <div className="flex flex-wrap gap-2">
value={status} <Input
onValueChange={(v) => { placeholder={t('admin.billing.searchPlaceholder')}
setStatus(v as PaymentRequestStatus | 'All') value={search}
setPage(1) onChange={(e) => {
}} setSearch(e.target.value)
> setPage(1)
<SelectTrigger className="w-56"> }}
<SelectValue /> className="max-w-xs"
</SelectTrigger> />
<SelectContent> <Select
{STATUS_FILTERS.map((s) => ( value={status}
<SelectItem key={s} value={s}> onValueChange={(v) => {
{s === 'All' ? t('admin.billing.allStatuses') : t(`admin.billing.status.${s}`)} setStatus(v as PaymentRequestStatus | 'All')
</SelectItem> setPage(1)
))} }}
</SelectContent> >
</Select> <SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_FILTERS.map((s) => (
<SelectItem key={s} value={s}>
{s === 'All' ? t('admin.billing.allStatuses') : t(`admin.billing.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={kind}
onValueChange={(v) => {
setKind(v as PaymentRequestKind | 'All')
setPage(1)
}}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{KIND_FILTERS.map((k) => (
<SelectItem key={k} value={k}>
{k === 'All' ? t('admin.billing.allKinds') : k === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t('billing.subscription')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>} {isLoading && <p className="text-sm text-muted-foreground"></p>}
+50 -3
View File
@@ -11,7 +11,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { formatBytes } from '@/shared/lib/format' import { formatBytes } from '@/shared/lib/format'
import { listAllConfigs } from '@/features/admin/configs/api' import { listAllConfigs } from '@/features/admin/configs/api'
import { forceRevokeConfig } from '@/features/admin/users/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 }) export const Route = createFileRoute('/admin/configs')({ component: AdminConfigsPage })
@@ -30,13 +33,19 @@ function AdminConfigsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [status, setStatus] = useState<ConfigStatus | 'all'>('all') const [status, setStatus] = useState<ConfigStatus | 'all'>('all')
const [protocol, setProtocol] = useState<VpnProtocol | 'all'>('all')
const [nodeId, setNodeId] = useState<string>('all')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const statusFilter = status === 'all' ? undefined : status 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({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-configs', page, search, statusFilter], queryKey: ['admin-configs', page, search, statusFilter, protocolFilter, nodeFilter],
queryFn: () => listAllConfigs(page, PAGE_SIZE, search || undefined, statusFilter), queryFn: () => listAllConfigs(page, PAGE_SIZE, search || undefined, statusFilter, protocolFilter, nodeFilter),
}) })
const revokeMutation = useMutation({ const revokeMutation = useMutation({
@@ -79,6 +88,44 @@ function AdminConfigsPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select
value={protocol}
onValueChange={(value) => {
setProtocol(value as VpnProtocol | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.configs.allProtocols')}</SelectItem>
{PROTOCOLS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={nodeId}
onValueChange={(value) => {
setNodeId(value)
setPage(1)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.configs.allNodes')}</SelectItem>
{nodesQuery.data?.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{isLoading && <p className="text-sm text-muted-foreground"></p>} {isLoading && <p className="text-sm text-muted-foreground"></p>}
+53 -2
View File
@@ -4,9 +4,14 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' 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 { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDetailDialog' import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDetailDialog'
import { listAllTickets } from '@/features/admin/support/api' 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')({ export const Route = createFileRoute('/admin/support')({
component: AdminSupportPage, component: AdminSupportPage,
@@ -24,14 +29,60 @@ function AdminSupportPage() {
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })
const { ticket: selectedTicketId } = Route.useSearch() const { ticket: selectedTicketId } = Route.useSearch()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [type, setType] = useState<TicketType | 'all'>('all')
const [status, setStatus] = useState<TicketStatus | 'all'>('all')
const typeFilter = type === 'all' ? undefined : type
const statusFilter = status === 'all' ? undefined : status
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-tickets', page], queryKey: ['admin-tickets', page, typeFilter, statusFilter],
queryFn: () => listAllTickets(undefined, undefined, page, PAGE_SIZE), queryFn: () => listAllTickets(typeFilter, statusFilter, page, PAGE_SIZE),
}) })
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Select
value={type}
onValueChange={(v) => {
setType(v as TicketType | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.support.allTypes')}</SelectItem>
{TYPES.map((ty) => (
<SelectItem key={ty} value={ty}>
{t(`support.type.${ty}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={status}
onValueChange={(v) => {
setStatus(v as TicketStatus | 'all')
setPage(1)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.support.allStatuses')}</SelectItem>
{STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{t(`support.status.${st}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>} {isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && ( {isError && (
+80 -11
View File
@@ -5,17 +5,25 @@ import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge' import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
import { listUsers } from '@/features/admin/users/api' import { listUsers } from '@/features/admin/users/api'
import { listRoles } from '@/features/admin/roles/api'
import { UserManageDialog } from '@/features/admin/users/UserManageDialog' import { UserManageDialog } from '@/features/admin/users/UserManageDialog'
export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage }) export const Route = createFileRoute('/admin/users')({ component: AdminUsersPage })
const PAGE_SIZE = 20 const PAGE_SIZE = 20
type StatusFilter = 'all' | 'active' | 'pending' | 'blocked'
type BillingFilter = 'all' | 'expired' | 'paid'
function AdminUsersPage() { function AdminUsersPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [roleId, setRoleId] = useState<string>('all')
const [status, setStatus] = useState<StatusFilter>('all')
const [billing, setBilling] = useState<BillingFilter>('all')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// Id, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит // Id, не сам объект — иначе диалог держит "замороженный" снимок пользователя и не видит
// изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп // изменения, сделанные им же самим (гифт/блок/смена роли инвалидируют этот запрос, но проп
@@ -23,23 +31,84 @@ function AdminUsersPage() {
// запроса на каждый рендер. // запроса на каждый рендер.
const [managingId, setManagingId] = useState<string | null>(null) const [managingId, setManagingId] = useState<string | null>(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({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-users', page, search], queryKey: ['admin-users', page, search, roleFilter, isActivated, isBlocked, billingExpired],
queryFn: () => listUsers(page, PAGE_SIZE, search || undefined), queryFn: () => listUsers(page, PAGE_SIZE, search || undefined, roleFilter, isActivated, isBlocked, billingExpired),
}) })
const managingUser = managingId ? (data?.items.find((u) => u.id === managingId) ?? null) : null const managingUser = managingId ? (data?.items.find((u) => u.id === managingId) ?? null) : null
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Input <div className="flex flex-wrap gap-2">
placeholder={t('admin.users.searchPlaceholder')} <Input
value={search} placeholder={t('admin.users.searchPlaceholder')}
onChange={(e) => { value={search}
setSearch(e.target.value) onChange={(e) => {
setPage(1) setSearch(e.target.value)
}} setPage(1)
className="max-w-sm" }}
/> className="max-w-sm"
/>
<Select
value={roleId}
onValueChange={(v) => {
setRoleId(v)
setPage(1)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.users.allRoles')}</SelectItem>
{rolesQuery.data?.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={status}
onValueChange={(v) => {
setStatus(v as StatusFilter)
setPage(1)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.users.allStatuses')}</SelectItem>
<SelectItem value="active">{t('admin.users.status.active')}</SelectItem>
<SelectItem value="pending">{t('admin.users.status.pending')}</SelectItem>
<SelectItem value="blocked">{t('admin.users.status.blocked')}</SelectItem>
</SelectContent>
</Select>
<Select
value={billing}
onValueChange={(v) => {
setBilling(v as BillingFilter)
setPage(1)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.users.allBilling')}</SelectItem>
<SelectItem value="expired">{t('admin.users.billingExpired')}</SelectItem>
<SelectItem value="paid">{t('admin.users.billingPaid')}</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>} {isLoading && <p className="text-sm text-muted-foreground"></p>}
+46
View File
@@ -149,6 +149,7 @@ const resources = {
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.', awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
roleChangeTopUp: 'Доплата за смену роли', roleChangeTopUp: 'Доплата за смену роли',
roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.', roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
subscription: 'Подписка',
}, },
instructions: { instructions: {
@@ -272,6 +273,11 @@ const resources = {
manage: 'Управление', manage: 'Управление',
empty: 'Пользователи не найдены.', empty: 'Пользователи не найдены.',
total: 'Всего: {{count}}', total: 'Всего: {{count}}',
allRoles: 'Все роли',
allStatuses: 'Все статусы',
allBilling: 'Любая оплата',
billingExpired: 'Просрочена',
billingPaid: 'Оплачена',
status: { status: {
blocked: 'Заблокирован', blocked: 'Заблокирован',
active: 'Активен', active: 'Активен',
@@ -303,6 +309,8 @@ const resources = {
traffic: 'Трафик', traffic: 'Трафик',
statusLabel: 'Статус', statusLabel: 'Статус',
allStatuses: 'Все статусы', allStatuses: 'Все статусы',
allProtocols: 'Все протоколы',
allNodes: 'Все ноды',
created: 'Создан', created: 'Создан',
empty: 'Конфиги не найдены.', empty: 'Конфиги не найдены.',
total: 'Всего: {{count}}', total: 'Всего: {{count}}',
@@ -313,6 +321,12 @@ const resources = {
rejected: 'Запрос отклонён.', rejected: 'Запрос отклонён.',
approve: 'Активировать', approve: 'Активировать',
reject: 'Отклонить', reject: 'Отклонить',
allStatuses: 'Все статусы',
status: {
Pending: 'Ожидает',
Approved: 'Одобрен',
Rejected: 'Отклонён',
},
}, },
roles: { roles: {
create: 'Создать роль', create: 'Создать роль',
@@ -368,7 +382,9 @@ const resources = {
defaultBillingEnabledForNewRolesLabel: 'Новые роли по умолчанию с включённым биллингом', defaultBillingEnabledForNewRolesLabel: 'Новые роли по умолчанию с включённым биллингом',
settingsUpdated: 'Настройки биллинга обновлены.', settingsUpdated: 'Настройки биллинга обновлены.',
requestsTitle: 'Заявки на оплату', requestsTitle: 'Заявки на оплату',
searchPlaceholder: 'Поиск по имени пользователя',
allStatuses: 'Все статусы', allStatuses: 'Все статусы',
allKinds: 'Все виды',
user: 'Пользователь', user: 'Пользователь',
period: 'Период', period: 'Период',
amount: 'Сумма', amount: 'Сумма',
@@ -420,6 +436,8 @@ const resources = {
published: 'Опубликован', published: 'Опубликован',
unpublished: 'Не опубликован', unpublished: 'Не опубликован',
unavailable: 'Недоступен на панели', unavailable: 'Недоступен на панели',
inboundDeleted: 'Инбаунд удалён.',
confirmDeleteInbound: 'Удалить инбаунд? Все ещё активные конфиги на нём будут отозваны.',
publishSaved: 'Настройки публикации сохранены.', publishSaved: 'Настройки публикации сохранены.',
displayName: 'Отображаемое имя', displayName: 'Отображаемое имя',
allowedRoles: 'Доступно ролям', allowedRoles: 'Доступно ролям',
@@ -478,6 +496,8 @@ const resources = {
approved: 'Заявка одобрена, роль выдана.', approved: 'Заявка одобрена, роль выдана.',
reject: 'Отклонить', reject: 'Отклонить',
rejected: 'Заявка отклонена.', rejected: 'Заявка отклонена.',
allTypes: 'Все типы',
allStatuses: 'Все статусы',
}, },
audit: { audit: {
time: 'Время', time: 'Время',
@@ -485,6 +505,9 @@ const resources = {
target: 'Объект', target: 'Объект',
source: 'Источник', source: 'Источник',
empty: 'Журнал аудита пуст.', empty: 'Журнал аудита пуст.',
actionPlaceholder: 'Поиск по действию',
allSources: 'Все источники',
allTargetTypes: 'Все типы объектов',
}, },
maintenance: { maintenance: {
closedTickets: { 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.', awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.',
roleChangeTopUp: 'Role change top-up', 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.", 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: { instructions: {
@@ -817,6 +841,11 @@ const resources = {
manage: 'Manage', manage: 'Manage',
empty: 'No users found.', empty: 'No users found.',
total: 'Total: {{count}}', total: 'Total: {{count}}',
allRoles: 'All roles',
allStatuses: 'All statuses',
allBilling: 'Any billing',
billingExpired: 'Expired',
billingPaid: 'Paid',
status: { status: {
blocked: 'Blocked', blocked: 'Blocked',
active: 'Active', active: 'Active',
@@ -848,6 +877,8 @@ const resources = {
traffic: 'Traffic', traffic: 'Traffic',
statusLabel: 'Status', statusLabel: 'Status',
allStatuses: 'All statuses', allStatuses: 'All statuses',
allProtocols: 'All protocols',
allNodes: 'All nodes',
created: 'Created', created: 'Created',
empty: 'No configs found.', empty: 'No configs found.',
total: 'Total: {{count}}', total: 'Total: {{count}}',
@@ -858,6 +889,12 @@ const resources = {
rejected: 'Request rejected.', rejected: 'Request rejected.',
approve: 'Approve', approve: 'Approve',
reject: 'Reject', reject: 'Reject',
allStatuses: 'All statuses',
status: {
Pending: 'Pending',
Approved: 'Approved',
Rejected: 'Rejected',
},
}, },
roles: { roles: {
create: 'Create role', create: 'Create role',
@@ -913,7 +950,9 @@ const resources = {
defaultBillingEnabledForNewRolesLabel: 'New roles default to billing enabled', defaultBillingEnabledForNewRolesLabel: 'New roles default to billing enabled',
settingsUpdated: 'Billing settings updated.', settingsUpdated: 'Billing settings updated.',
requestsTitle: 'Payment requests', requestsTitle: 'Payment requests',
searchPlaceholder: 'Search by username',
allStatuses: 'All statuses', allStatuses: 'All statuses',
allKinds: 'All kinds',
user: 'User', user: 'User',
period: 'Period', period: 'Period',
amount: 'Amount', amount: 'Amount',
@@ -965,6 +1004,8 @@ const resources = {
published: 'Published', published: 'Published',
unpublished: 'Not published', unpublished: 'Not published',
unavailable: 'Unavailable on panel', unavailable: 'Unavailable on panel',
inboundDeleted: 'Inbound deleted.',
confirmDeleteInbound: 'Delete this inbound? Any still-active configs on it will be revoked.',
publishSaved: 'Publishing settings saved.', publishSaved: 'Publishing settings saved.',
displayName: 'Display name', displayName: 'Display name',
allowedRoles: 'Allowed for roles', allowedRoles: 'Allowed for roles',
@@ -1023,6 +1064,8 @@ const resources = {
approved: 'Request approved, role granted.', approved: 'Request approved, role granted.',
reject: 'Reject', reject: 'Reject',
rejected: 'Request rejected.', rejected: 'Request rejected.',
allTypes: 'All types',
allStatuses: 'All statuses',
}, },
audit: { audit: {
time: 'Time', time: 'Time',
@@ -1030,6 +1073,9 @@ const resources = {
target: 'Target', target: 'Target',
source: 'Source', source: 'Source',
empty: 'The audit log is empty.', empty: 'The audit log is empty.',
actionPlaceholder: 'Search by action',
allSources: 'All sources',
allTargetTypes: 'All target types',
}, },
maintenance: { maintenance: {
closedTickets: { closedTickets: {