Enhance admin endpoints and queries for improved filtering and management
- 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:
@@ -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
|
||||||
|
|||||||
+75
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-3
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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` — свежий конфиг сразу несёт правильный срок.
|
||||||
|
|||||||
@@ -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()}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user