Refactor VPN configuration handling to remove device limit management
- Updated the VPN configuration commands and handlers to eliminate the device limit parameter, simplifying the configuration process. - Adjusted related API documentation to reflect the removal of device limit management, clarifying that this setting is now handled directly in the 3x-ui by node administrators. - Enhanced the overall codebase by removing unnecessary device limit references across various components, ensuring a cleaner and more maintainable code structure.
This commit is contained in:
@@ -89,7 +89,7 @@
|
||||
(append-only, источник Web/Telegram/System).
|
||||
- **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу.
|
||||
- **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот — read-only** по конфигам.
|
||||
- **Конфиг**: пользователь задаёт метку (`Label`) и лимит устройств (`DeviceLimit` → `limitIp` в 3x-ui, 0=без лимита), может редактировать.
|
||||
- **Конфиг**: пользователь задаёт метку (`Label`), может редактировать. Лимит устройств (`limitIp` в 3x-ui) панелью не управляется — более сложная настройка, задаётся при необходимости администратором ноды напрямую в 3x-ui.
|
||||
- **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется.
|
||||
- **API без версионирования** (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`.
|
||||
- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage).
|
||||
|
||||
@@ -44,14 +44,14 @@ public static class ConfigEndpoints
|
||||
|
||||
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new CreateVpnConfigCommand(body.InboundId, body.Label, body.DeviceLimit);
|
||||
var command = new CreateVpnConfigCommand(body.InboundId, body.Label);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new EditVpnConfigCommand(id, body.Label, body.DeviceLimit);
|
||||
var command = new EditVpnConfigCommand(id, body.Label);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
@@ -89,9 +89,9 @@ public static class ConfigEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit);
|
||||
public sealed record CreateConfigBody(Guid InboundId, string? Label);
|
||||
|
||||
public sealed record EditConfigBody(string? Label, int? DeviceLimit);
|
||||
public sealed record EditConfigBody(string? Label);
|
||||
|
||||
public sealed record ConfigLinkResponseDto(string ConnectionString, string SubscriptionUrl);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class BlockUserCommandHandler(
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, config.DeviceLimit, enable: false, cancellationToken);
|
||||
config.Label ?? config.ClientEmail, enable: false, cancellationToken);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class UnblockUserCommandHandler(
|
||||
{
|
||||
var updateResult = await gateway.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, config.DeviceLimit, enable: true, cancellationToken);
|
||||
config.Label ?? config.ClientEmail, enable: true, cancellationToken);
|
||||
|
||||
if (!updateResult.IsSuccess)
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ public interface IXuiPanelGateway
|
||||
/// <summary>Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).</summary>
|
||||
Task<Result<string>> AddClientAsync(
|
||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
|
||||
int deviceLimit, CancellationToken cancellationToken);
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> RemoveClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
|
||||
@@ -35,7 +35,7 @@ public interface IXuiPanelGateway
|
||||
|
||||
Task<Result> UpdateClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
|
||||
string name, int deviceLimit, bool enable, CancellationToken cancellationToken);
|
||||
string name, bool enable, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result<string>> BuildConnectionStringAsync(
|
||||
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost,
|
||||
|
||||
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Configs.Create;
|
||||
|
||||
public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label, int? DeviceLimit) : ICommand<Result<VpnConfigDto>>;
|
||||
public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label) : ICommand<Result<VpnConfigDto>>;
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class CreateVpnConfigCommandHandler(
|
||||
if (node is null || !node.IsEnabled)
|
||||
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
|
||||
|
||||
var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label, command.DeviceLimit ?? 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label);
|
||||
|
||||
var reserveResult = await ReserveQuotaSlotAsync(userId, profile.MaxConfigs, config, cancellationToken);
|
||||
if (!reserveResult.IsSuccess)
|
||||
@@ -44,7 +44,7 @@ public sealed class CreateVpnConfigCommandHandler(
|
||||
|
||||
var addResult = await gateway.AddClientAsync(
|
||||
node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail,
|
||||
config.Label ?? config.ClientEmail, config.DeviceLimit, cancellationToken);
|
||||
config.Label ?? config.ClientEmail, cancellationToken);
|
||||
|
||||
if (!addResult.IsSuccess)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,5 @@ public sealed class CreateVpnConfigCommandValidator : AbstractValidator<CreateVp
|
||||
{
|
||||
RuleFor(x => x.InboundId).NotEmpty();
|
||||
RuleFor(x => x.Label).MaximumLength(100);
|
||||
RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Configs.Edit;
|
||||
|
||||
public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label, int? DeviceLimit) : ICommand<Result<VpnConfigDto>>;
|
||||
public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label) : ICommand<Result<VpnConfigDto>>;
|
||||
|
||||
@@ -25,18 +25,15 @@ public sealed class EditVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPan
|
||||
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAvailable);
|
||||
|
||||
if (command.Label is not null)
|
||||
config.Rename(command.Label);
|
||||
|
||||
if (command.DeviceLimit is { } deviceLimit)
|
||||
{
|
||||
config.SetDeviceLimit(deviceLimit);
|
||||
config.Rename(command.Label);
|
||||
|
||||
var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||
if (node is not null)
|
||||
{
|
||||
await gateway.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, deviceLimit, enable: true, cancellationToken);
|
||||
config.Label ?? config.ClientEmail, enable: true, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,5 @@ public sealed class EditVpnConfigCommandValidator : AbstractValidator<EditVpnCon
|
||||
public EditVpnConfigCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Label).MaximumLength(100);
|
||||
RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class RotateVpnConfigCommandHandler(IAppDbContext dbContext, IXuiP
|
||||
var newClientEmail = VpnConfig.GenerateClientEmail(userId);
|
||||
var addResult = await gateway.AddClientAsync(
|
||||
node, inbound.RemoteInboundId, config.Protocol, newClientEmail,
|
||||
config.Label ?? newClientEmail, config.DeviceLimit, cancellationToken);
|
||||
config.Label ?? newClientEmail, cancellationToken);
|
||||
|
||||
if (!addResult.IsSuccess)
|
||||
return Result.Failure<VpnConfigDto>(addResult.Error);
|
||||
|
||||
@@ -8,10 +8,10 @@ namespace PnvPanel.Application.Configs;
|
||||
/// детали 3x-ui в этот DTO не попадают (см. domain-model.md).
|
||||
/// </summary>
|
||||
public sealed record VpnConfigDto(
|
||||
Guid Id, string? Label, VpnProtocol Protocol, string Location, int DeviceLimit,
|
||||
Guid Id, string? Label, VpnProtocol Protocol, string Location,
|
||||
long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt, ConfigStatus Status, DateTimeOffset CreatedAt)
|
||||
{
|
||||
public static VpnConfigDto FromDomain(VpnConfig config, Inbound inbound) => new(
|
||||
config.Id, config.Label, config.Protocol, inbound.DisplayName ?? inbound.Remark, config.DeviceLimit,
|
||||
config.Id, config.Label, config.Protocol, inbound.DisplayName ?? inbound.Remark,
|
||||
config.UsedUpBytes, config.UsedDownBytes, config.ExpiresAt, config.Status, config.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ public sealed class VpnConfig : Entity
|
||||
public string ClientEmail { get; private set; } = string.Empty;
|
||||
public string ClientExternalId { get; private set; } = string.Empty;
|
||||
public VpnProtocol Protocol { get; private set; }
|
||||
public int DeviceLimit { get; private set; }
|
||||
public long UsedUpBytes { get; private set; }
|
||||
public long UsedDownBytes { get; private set; }
|
||||
public DateTimeOffset? ExpiresAt { get; private set; }
|
||||
@@ -31,7 +30,7 @@ public sealed class VpnConfig : Entity
|
||||
{
|
||||
}
|
||||
|
||||
public static VpnConfig Create(Guid userId, Guid inboundId, VpnProtocol protocol, string? label, int deviceLimit)
|
||||
public static VpnConfig Create(Guid userId, Guid inboundId, VpnProtocol protocol, string? label)
|
||||
{
|
||||
return new VpnConfig
|
||||
{
|
||||
@@ -42,7 +41,6 @@ public sealed class VpnConfig : Entity
|
||||
ClientEmail = GenerateClientEmail(userId),
|
||||
ClientExternalId = string.Empty,
|
||||
Label = label,
|
||||
DeviceLimit = deviceLimit,
|
||||
Status = ConfigStatus.Active,
|
||||
SubscriptionToken = GenerateToken(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
@@ -54,8 +52,6 @@ public sealed class VpnConfig : Entity
|
||||
|
||||
public void Rename(string? label) => Label = label;
|
||||
|
||||
public void SetDeviceLimit(int deviceLimit) => DeviceLimit = deviceLimit;
|
||||
|
||||
public void Rotate(string newClientEmail, string newClientExternalId)
|
||||
{
|
||||
EnsureActive("перевыпустить");
|
||||
|
||||
+744
@@ -0,0 +1,744 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260702201236_RemoveDeviceLimit")]
|
||||
partial class RemoveDeviceLimit
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("ActivationRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("DownloadUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatingSystem")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ClientExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("InboundId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("UsedDownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UsedUpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InboundId");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("VpnConfigs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("MaxClients")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("RemoteInboundId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NodeId", "RemoteInboundId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Inbounds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BaseAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TelegramLinkTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TelegramLoginRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ActivatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("TelegramUsername")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TelegramUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("ProtectedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("CredentialsProtectedPassword");
|
||||
|
||||
b1.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("CredentialsUsername");
|
||||
|
||||
b1.HasKey("NodeId");
|
||||
|
||||
b1.ToTable("Nodes");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("NodeId");
|
||||
});
|
||||
|
||||
b.Navigation("Credentials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveDeviceLimit : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeviceLimit",
|
||||
table: "VpnConfigs");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DeviceLimit",
|
||||
table: "VpnConfigs",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -293,9 +293,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DeviceLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
||||
@@ -71,12 +71,14 @@ internal sealed class XuiPanelGateway(
|
||||
|
||||
public async Task<Result<string>> AddClientAsync(
|
||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
|
||||
int deviceLimit, CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = GetClient(node);
|
||||
var request = new AddClientRequest(clientName, clientEmail, ToRemoteProtocol(protocol), deviceLimit, null);
|
||||
// Лимит устройств (limitIp) панелью больше не управляется — задаётся, если нужно,
|
||||
// напрямую в 3x-ui администратором ноды. Новый клиент всегда создаётся без лимита.
|
||||
var request = new AddClientRequest(clientName, clientEmail, ToRemoteProtocol(protocol), 0, null);
|
||||
var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken);
|
||||
return Result.Success(result.ExternalClientId);
|
||||
}
|
||||
@@ -103,12 +105,13 @@ internal sealed class XuiPanelGateway(
|
||||
|
||||
public async Task<Result> UpdateClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
|
||||
string name, int deviceLimit, bool enable, CancellationToken cancellationToken)
|
||||
string name, bool enable, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = GetClient(node);
|
||||
var request = new UpdateClientRequest(deviceLimit, null, enable, name);
|
||||
// deviceLimit: null — не трогаем то, что уже стоит на клиенте в панели (см. AddClientAsync).
|
||||
var request = new UpdateClientRequest(null, null, enable, name);
|
||||
await client.UpdateClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), request, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@ public class BlockUserCommandHandlerTests
|
||||
Assert.Equal(failure, result.Error);
|
||||
await _gateway.DidNotReceive().UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -48,7 +48,7 @@ public class BlockUserCommandHandlerTests
|
||||
|
||||
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
@@ -59,7 +59,7 @@ public class BlockUserCommandHandlerTests
|
||||
_currentUser.UserId.Returns(adminId);
|
||||
_gateway.UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
|
||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger);
|
||||
@@ -71,7 +71,7 @@ public class BlockUserCommandHandlerTests
|
||||
|
||||
await _gateway.Received(1).UpdateClientAsync(
|
||||
Arg.Is<Node>(n => n.Id == node.Id), inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
"my-config", config.DeviceLimit, enable: false, Arg.Any<CancellationToken>());
|
||||
"my-config", enable: false, Arg.Any<CancellationToken>());
|
||||
await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Disabled, Arg.Any<CancellationToken>());
|
||||
|
||||
var audit = Assert.Single(dbContext.AuditLogs.Local);
|
||||
@@ -95,7 +95,7 @@ public class BlockUserCommandHandlerTests
|
||||
Assert.True(result.IsSuccess);
|
||||
await _gateway.DidNotReceive().UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -107,7 +107,7 @@ public class BlockUserCommandHandlerTests
|
||||
|
||||
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
@@ -118,7 +118,7 @@ public class BlockUserCommandHandlerTests
|
||||
_currentUser.UserId.Returns(adminId);
|
||||
_gateway.UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна.")));
|
||||
|
||||
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger);
|
||||
|
||||
+5
-5
@@ -28,7 +28,7 @@ public class UnblockUserCommandHandlerTests
|
||||
|
||||
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||
config.Disable();
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
@@ -40,7 +40,7 @@ public class UnblockUserCommandHandlerTests
|
||||
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
_gateway.UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
|
||||
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger);
|
||||
@@ -51,7 +51,7 @@ public class UnblockUserCommandHandlerTests
|
||||
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||
await _gateway.Received(1).UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
Arg.Any<string>(), config.DeviceLimit, true, Arg.Any<CancellationToken>());
|
||||
Arg.Any<string>(), true, Arg.Any<CancellationToken>());
|
||||
await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Active, Arg.Any<CancellationToken>());
|
||||
Assert.Single(dbContext.AuditLogs.Local);
|
||||
Assert.Equal("UserUnblocked", dbContext.AuditLogs.Local.Single().Action);
|
||||
@@ -84,7 +84,7 @@ public class UnblockUserCommandHandlerTests
|
||||
|
||||
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||
config.Disable();
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
@@ -96,7 +96,7 @@ public class UnblockUserCommandHandlerTests
|
||||
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
_gateway.UpdateClientAsync(
|
||||
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна.")));
|
||||
|
||||
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger);
|
||||
|
||||
+3
-3
@@ -23,10 +23,10 @@ public class GetMyConfigsQueryHandlerTests
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
inbound.Publish("My inbound", [], null);
|
||||
|
||||
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active", 0);
|
||||
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked", 0);
|
||||
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active");
|
||||
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked");
|
||||
revokedConfig.Revoke();
|
||||
var otherUsersConfig = VpnConfig.Create(otherUserId, inbound.Id, VpnProtocol.Vless, "Other", 0);
|
||||
var otherUsersConfig = VpnConfig.Create(otherUserId, inbound.Id, VpnProtocol.Vless, "Other");
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig);
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ public class RevokeVpnConfigCommandHandlerTests
|
||||
|
||||
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||
config.AssignRemoteClient("external-id");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
@@ -49,7 +49,7 @@ public class RevokeVpnConfigCommandHandlerTests
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||
config.Revoke();
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
|
||||
+6
-6
@@ -24,7 +24,7 @@ public class RotateVpnConfigCommandHandlerTests
|
||||
|
||||
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config");
|
||||
config.AssignRemoteClient("old-external-id");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
@@ -34,7 +34,7 @@ public class RotateVpnConfigCommandHandlerTests
|
||||
|
||||
_gateway.AddClientAsync(
|
||||
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
||||
Arg.Any<string>(), config.DeviceLimit, Arg.Any<CancellationToken>())
|
||||
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success("new-external-id"));
|
||||
|
||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
||||
@@ -69,7 +69,7 @@ public class RotateVpnConfigCommandHandlerTests
|
||||
var otherUserId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(ownerId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
var config = VpnConfig.Create(ownerId, inbound.Id, VpnProtocol.Vless, null);
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
@@ -90,7 +90,7 @@ public class RotateVpnConfigCommandHandlerTests
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||
config.Revoke();
|
||||
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
@@ -113,7 +113,7 @@ public class RotateVpnConfigCommandHandlerTests
|
||||
|
||||
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 config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null, 0);
|
||||
var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||
config.AssignRemoteClient("old-external-id");
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
@@ -124,7 +124,7 @@ public class RotateVpnConfigCommandHandlerTests
|
||||
var gatewayError = Error.Failure("Xui.Unreachable", "Панель недоступна.");
|
||||
_gateway.AddClientAsync(
|
||||
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(),
|
||||
Arg.Any<string>(), config.DeviceLimit, Arg.Any<CancellationToken>())
|
||||
Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Failure<string>(gatewayError));
|
||||
|
||||
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
|
||||
|
||||
@@ -13,13 +13,12 @@ public class VpnConfigTests
|
||||
var userId = Guid.NewGuid();
|
||||
var inboundId = Guid.NewGuid();
|
||||
|
||||
var config = VpnConfig.Create(userId, inboundId, VpnProtocol.Vless, "My device", deviceLimit: 3);
|
||||
var config = VpnConfig.Create(userId, inboundId, VpnProtocol.Vless, "My device");
|
||||
|
||||
Assert.Equal(userId, config.UserId);
|
||||
Assert.Equal(inboundId, config.InboundId);
|
||||
Assert.Equal(VpnProtocol.Vless, config.Protocol);
|
||||
Assert.Equal("My device", config.Label);
|
||||
Assert.Equal(3, config.DeviceLimit);
|
||||
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||
Assert.Equal(string.Empty, config.ClientExternalId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(config.ClientEmail));
|
||||
@@ -32,8 +31,8 @@ public class VpnConfigTests
|
||||
public void Create_GeneratesUniqueSubscriptionTokensAndClientEmails()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var a = VpnConfig.Create(userId, Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var b = VpnConfig.Create(userId, Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var a = VpnConfig.Create(userId, Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
var b = VpnConfig.Create(userId, Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
|
||||
Assert.NotEqual(a.SubscriptionToken, b.SubscriptionToken);
|
||||
Assert.NotEqual(a.ClientEmail, b.ClientEmail);
|
||||
@@ -42,7 +41,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void AssignRemoteClient_SetsClientExternalId()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Trojan, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Trojan, null);
|
||||
|
||||
config.AssignRemoteClient("some-remote-password");
|
||||
|
||||
@@ -52,7 +51,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void Rotate_WhenActive_ChangesEmailExternalIdAndToken()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
config.AssignRemoteClient("old-id");
|
||||
var oldToken = config.SubscriptionToken;
|
||||
var oldEmail = config.ClientEmail;
|
||||
@@ -70,7 +69,7 @@ public class VpnConfigTests
|
||||
[InlineData(ConfigStatus.Disabled)]
|
||||
public void Rotate_WhenNotActive_Throws(ConfigStatus status)
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
MoveToStatus(config, status);
|
||||
|
||||
Assert.Throws<DomainException>(() => config.Rotate("e", "i"));
|
||||
@@ -79,7 +78,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void Revoke_WhenActive_SetsRevokedStatus()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
|
||||
config.Revoke();
|
||||
|
||||
@@ -89,7 +88,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void Revoke_WhenAlreadyRevoked_Throws()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
config.Revoke();
|
||||
|
||||
Assert.Throws<DomainException>(() => config.Revoke());
|
||||
@@ -98,7 +97,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void Disable_WhenActive_SetsDisabled()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
|
||||
config.Disable();
|
||||
|
||||
@@ -108,7 +107,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void Disable_WhenRevoked_DoesNotChangeStatus()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
config.Revoke();
|
||||
|
||||
config.Disable();
|
||||
@@ -119,7 +118,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void Enable_WhenDisabled_ReturnsToActive()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
config.Disable();
|
||||
|
||||
config.Enable();
|
||||
@@ -130,7 +129,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void Enable_WhenRevoked_DoesNotResurrect()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
config.Revoke();
|
||||
|
||||
config.Enable();
|
||||
@@ -141,7 +140,7 @@ public class VpnConfigTests
|
||||
[Fact]
|
||||
public void UpdateTraffic_SetsBytesAndLastSyncAt()
|
||||
{
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null, 1);
|
||||
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||
|
||||
config.UpdateTraffic(100, 200);
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
|
||||
{
|
||||
inboundId = inbound.Id,
|
||||
label = $"device-{i}",
|
||||
deviceLimit = (int?)null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway
|
||||
|
||||
public Task<Result<string>> AddClientAsync(
|
||||
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
|
||||
int deviceLimit, CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Result.Success(Guid.NewGuid().ToString()));
|
||||
|
||||
public Task<Result> RemoveClientAsync(
|
||||
@@ -41,7 +41,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway
|
||||
|
||||
public Task<Result> UpdateClientAsync(
|
||||
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
|
||||
string name, int deviceLimit, bool enable, CancellationToken cancellationToken)
|
||||
string name, bool enable, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(Result.Success());
|
||||
|
||||
public Task<Result<string>> BuildConnectionStringAsync(
|
||||
|
||||
+3
-3
@@ -65,15 +65,15 @@ rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 за
|
||||
| ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
|
||||
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
|
||||
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }` — **без пагинации**, весь список сразу |
|
||||
| POST | `/api/configs` | `{ inboundId, label?, deviceLimit? }`| `VpnConfigDto` (`200 OK`, не 201) |
|
||||
| PATCH | `/api/configs/{id}` | `{ label?, deviceLimit? }` | `VpnConfigDto` |
|
||||
| POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) |
|
||||
| PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` |
|
||||
| POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) |
|
||||
| DELETE | `/api/configs/{id}` | — | `204 No Content` |
|
||||
| GET | `/api/configs/{id}/link` | — | `{ connectionString, subscriptionUrl }` |
|
||||
| GET | `/api/subscription` | — | `{ subscriptionUrl }` |
|
||||
|
||||
**Нет отдельного `GET /api/configs/{id}`** — детали конфига берутся из списка `GET /api/configs`.
|
||||
`VpnConfigDto`: `{ id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes, expiresAt,
|
||||
`VpnConfigDto`: `{ id, label, protocol, location, usedUpBytes, usedDownBytes, expiresAt,
|
||||
status, createdAt }`. `expiresAt` всегда `null` (лимиты по сроку не реализованы — см.
|
||||
[domain-model.md](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт
|
||||
запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из
|
||||
|
||||
@@ -132,7 +132,7 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
||||
```
|
||||
POST /api/configs
|
||||
→ CreateVpnConfigCommand
|
||||
→ ValidationBehavior (FluentValidation — формат inboundId/label/deviceLimit)
|
||||
→ ValidationBehavior (FluentValidation — формат inboundId/label)
|
||||
→ CreateVpnConfigCommandHandler
|
||||
· проверяет активацию + роль инбаунда (доменные проверки)
|
||||
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
|
||||
@@ -141,7 +141,7 @@ POST /api/configs
|
||||
· VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
|
||||
· при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
|
||||
→ UnitOfWorkBehavior (commit транзакции)
|
||||
→ 200 OK VpnConfigDto { id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes,
|
||||
→ 200 OK VpnConfigDto { id, label, protocol, location, usedUpBytes, usedDownBytes,
|
||||
expiresAt, status, createdAt }
|
||||
```
|
||||
Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно,
|
||||
|
||||
@@ -80,7 +80,6 @@ ClientApp (каталог приложений-клиен
|
||||
| `ClientEmail` | `string` | Уникальный ключ клиента в 3x-ui; схема `pnv_{userIdShort}_{rand}` (уникален в рамках панели, виден владелец) |
|
||||
| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) |
|
||||
| `Protocol` | `VpnProtocol` | Денормализовано с inbound |
|
||||
| `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui |
|
||||
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
|
||||
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
|
||||
| `ExpiresAt` | `DateTimeOffset?`| Зарезервировано, сейчас ничего его не выставляет — конфиг живёт бессрочно |
|
||||
@@ -100,7 +99,8 @@ ClientApp (каталог приложений-клиен
|
||||
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
|
||||
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
|
||||
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
|
||||
- `Rename(label)` / `SetDeviceLimit(n)` → юзер меняет метку и лимит устройств (последнее синкается в `limitIp` 3x-ui).
|
||||
- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента). Лимит устройств/IP (`limitIp`
|
||||
в 3x-ui) панелью не управляется — более сложная per-node настройка, задаётся напрямую в 3x-ui администратором.
|
||||
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
|
||||
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
|
||||
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
|
||||
|
||||
+5
-3
@@ -87,10 +87,12 @@ frontend/
|
||||
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
|
||||
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
|
||||
неактивированного — `ActivationGate` вместо дашборда.
|
||||
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка + лимит устройств.
|
||||
После успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
|
||||
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка. Лимит устройств
|
||||
(`limitIp` в 3x-ui) панелью не управляется — задаётся при необходимости напрямую в 3x-ui. После
|
||||
успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
|
||||
- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
|
||||
сгруппированный по ОС; клик по приложению открывает ссылку на скачивание.
|
||||
сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает
|
||||
ссылку на скачивание.
|
||||
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
|
||||
удаление аккаунта с подтверждением (`DeleteAccountSection`).
|
||||
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
|
||||
|
||||
+1
-1
@@ -92,8 +92,8 @@
|
||||
| Подписка | Агрегированная на юзера (`AppUser.SubscriptionToken`) + по конфигу |
|
||||
| Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды |
|
||||
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
|
||||
| Лимит устройств | Per-config, задаёт юзер (`DeviceLimit` → `limitIp` в 3x-ui; 0 = без лимита) |
|
||||
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
|
||||
| Лимит устройств (`limitIp`) | Панелью не управляется — задаётся при необходимости напрямую в 3x-ui администратором ноды |
|
||||
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
|
||||
| Версионирование API | Без версий (`/api` без `v1`) |
|
||||
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import type { OsPlatform } from '@/shared/api/types'
|
||||
import { listApps } from './api'
|
||||
|
||||
@@ -10,19 +12,43 @@ const OS_ORDER: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
|
||||
export function AppsCatalog() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['client-apps'], queryFn: listApps })
|
||||
const [activeOs, setActiveOs] = useState<OsPlatform | null>(null)
|
||||
|
||||
const availableOs = data ? OS_ORDER.filter((os) => data[os] && data[os]!.length > 0) : []
|
||||
|
||||
useEffect(() => {
|
||||
if (availableOs.length > 0 && (!activeOs || !availableOs.includes(activeOs))) {
|
||||
setActiveOs(availableOs[0])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
|
||||
if (isLoading) return null
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
if (!data || availableOs.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{t('instructions.noApps')}</p>
|
||||
}
|
||||
|
||||
const apps = activeOs ? data[activeOs] ?? [] : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{OS_ORDER.filter((os) => data[os] && data[os]!.length > 0).map((os) => (
|
||||
<div key={os} className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">{t(`instructions.os.${os}`)}</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<nav className="flex gap-1 overflow-x-auto border-b border-border">
|
||||
{availableOs.map((os) => (
|
||||
<button
|
||||
key={os}
|
||||
type="button"
|
||||
onClick={() => setActiveOs(os)}
|
||||
className={cn(
|
||||
'shrink-0 whitespace-nowrap px-3 py-2 text-sm text-muted-foreground hover:text-foreground',
|
||||
os === activeOs && 'border-b-2 border-primary font-medium text-foreground',
|
||||
)}
|
||||
>
|
||||
{t(`instructions.os.${os}`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{data[os]!.map((app) => (
|
||||
{apps.map((app) => (
|
||||
<a key={app.id} href={app.downloadUrl} target="_blank" rel="noreferrer">
|
||||
<Card className="transition-colors hover:bg-muted">
|
||||
<CardHeader className="flex-row items-center gap-3 space-y-0">
|
||||
@@ -41,7 +67,5 @@ export function AppsCatalog() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,11 +77,8 @@ export function ConfigCard({ config }: { config: VpnConfigDto }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)}
|
||||
</span>
|
||||
<span>{config.deviceLimit > 0 ? t('configs.deviceLimit', { count: config.deviceLimit }) : t('configs.deviceLimitUnlimited')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setDetailsOpen(true)}>
|
||||
|
||||
@@ -18,17 +18,15 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inboundId, setInboundId] = useState('')
|
||||
const [label, setLabel] = useState('')
|
||||
const [deviceLimit, setDeviceLimit] = useState('')
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createConfig(inboundId, label.trim() || undefined, deviceLimit ? Number(deviceLimit) : undefined),
|
||||
mutationFn: () => createConfig(inboundId, label.trim() || undefined),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('configs.created'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
|
||||
setOpen(false)
|
||||
setInboundId('')
|
||||
setLabel('')
|
||||
setDeviceLimit('')
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
@@ -75,17 +73,6 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
|
||||
<Label htmlFor="label">{t('configs.label')}</Label>
|
||||
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="deviceLimit">{t('configs.deviceLimitLabel')}</Label>
|
||||
<Input
|
||||
id="deviceLimit"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t('configs.deviceLimitPlaceholder')}
|
||||
value={deviceLimit}
|
||||
onChange={(e) => setDeviceLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!inboundId || createMutation.isPending}>
|
||||
{t('configs.create')}
|
||||
</Button>
|
||||
|
||||
@@ -15,17 +15,17 @@ export function getMyConfigs() {
|
||||
return apiRequest<GetMyConfigsResult>('/configs')
|
||||
}
|
||||
|
||||
export function createConfig(inboundId: string, label: string | undefined, deviceLimit: number | undefined) {
|
||||
export function createConfig(inboundId: string, label: string | undefined) {
|
||||
return apiRequest<VpnConfigDto>('/configs', {
|
||||
method: 'POST',
|
||||
body: { inboundId, label: label ?? null, deviceLimit: deviceLimit ?? null },
|
||||
body: { inboundId, label: label ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function editConfig(id: string, label: string | undefined, deviceLimit: number | undefined) {
|
||||
export function editConfig(id: string, label: string | undefined) {
|
||||
return apiRequest<VpnConfigDto>(`/configs/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: { label: label ?? null, deviceLimit: deviceLimit ?? null },
|
||||
body: { label: label ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ export function TelegramLinkCard() {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!meQuery.data?.telegramLinked) return
|
||||
if (!open || !meQuery.data?.telegramLinked) return
|
||||
setUser(meQuery.data)
|
||||
setOpen(false)
|
||||
toast.success(t('settings.telegramLinked'))
|
||||
}, [meQuery.data, setUser, t])
|
||||
}, [open, meQuery.data, setUser, t])
|
||||
|
||||
const unlinkMutation = useMutation({
|
||||
mutationFn: unlinkTelegram,
|
||||
@@ -91,6 +91,7 @@ export function TelegramLinkCard() {
|
||||
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
|
||||
{deepLink}
|
||||
</a>
|
||||
<p className="text-center text-sm text-muted-foreground">{t('auth.telegramQrHint')}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>
|
||||
|
||||
@@ -72,6 +72,7 @@ export function TelegramLoginButton() {
|
||||
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
|
||||
{deepLink}
|
||||
</a>
|
||||
<p className="text-center text-sm text-muted-foreground">{t('auth.telegramQrHint')}</p>
|
||||
</>
|
||||
)}
|
||||
{!deepLink && <p className="text-sm text-muted-foreground">{t('auth.telegramBotNotConfigured')}</p>}
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--font-sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* +12.5% к дефолтной Tailwind-шкале (line-height — заданные Tailwind соотношения, масштабируются вместе с размером). */
|
||||
--text-xs: 0.844rem;
|
||||
--text-sm: 0.984rem;
|
||||
--text-base: 1.125rem;
|
||||
--text-lg: 1.266rem;
|
||||
--text-xl: 1.406rem;
|
||||
--text-2xl: 1.688rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -61,7 +61,7 @@ function AdminRolesPage() {
|
||||
<td className="py-2">
|
||||
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
|
||||
</td>
|
||||
<td className="py-2">{role.maxConfigs < 0 ? t('configs.deviceLimitUnlimited') : role.maxConfigs}</td>
|
||||
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||
{t('admin.roles.edit')}
|
||||
|
||||
@@ -1849,8 +1849,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
inboundId: string;
|
||||
label: null | string;
|
||||
/** Format: int32 */
|
||||
deviceLimit: null | number | string;
|
||||
};
|
||||
CreateRoleCommand: {
|
||||
name: string;
|
||||
@@ -1867,8 +1865,6 @@ export interface components {
|
||||
};
|
||||
EditConfigBody: {
|
||||
label: null | string;
|
||||
/** Format: int32 */
|
||||
deviceLimit: null | number | string;
|
||||
};
|
||||
GetMyConfigsResult: {
|
||||
configs: components["schemas"]["VpnConfigDto"][];
|
||||
@@ -2071,8 +2067,6 @@ export interface components {
|
||||
label: null | string;
|
||||
protocol: components["schemas"]["VpnProtocol"];
|
||||
location: string;
|
||||
/** Format: int32 */
|
||||
deviceLimit: number | string;
|
||||
/** Format: int64 */
|
||||
usedUpBytes: number | string;
|
||||
/** Format: int64 */
|
||||
|
||||
@@ -51,7 +51,6 @@ export type VpnConfigDto = {
|
||||
label: string | null
|
||||
protocol: VpnProtocol
|
||||
location: string
|
||||
deviceLimit: number
|
||||
usedUpBytes: number
|
||||
usedDownBytes: number
|
||||
expiresAt: string | null
|
||||
|
||||
@@ -11,6 +11,7 @@ const resources = {
|
||||
light: 'Светлая',
|
||||
dark: 'Тёмная',
|
||||
system: 'Системная',
|
||||
unlimited: 'Без лимита',
|
||||
|
||||
auth: {
|
||||
loginTitle: 'Вход',
|
||||
@@ -31,6 +32,8 @@ const resources = {
|
||||
passwordHint: 'Не менее 8 символов, минимум одна заглавная буква и одна цифра.',
|
||||
or: 'или',
|
||||
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
|
||||
telegramQrHint:
|
||||
'Перейдите по ссылке или отсканируйте QR-код — откроется бот в Telegram. Если открываете его впервые, нажмите «Старт».',
|
||||
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
|
||||
telegramLoginRejected: 'Вход отклонён в Telegram.',
|
||||
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
|
||||
@@ -71,8 +74,6 @@ const resources = {
|
||||
selectLocation: 'Выберите локацию',
|
||||
location: 'Локация',
|
||||
label: 'Метка (необязательно)',
|
||||
deviceLimitLabel: 'Лимит устройств (необязательно)',
|
||||
deviceLimitPlaceholder: 'Без лимита',
|
||||
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
|
||||
created: 'Конфиг создан.',
|
||||
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
|
||||
@@ -87,10 +88,6 @@ const resources = {
|
||||
subscriptionLink: 'Ссылка-подписка (для клиента):',
|
||||
aggregatedSubscription: 'Общая подписка',
|
||||
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
|
||||
deviceLimit: '{{count}} устройство',
|
||||
deviceLimit_few: '{{count}} устройства',
|
||||
deviceLimit_many: '{{count}} устройств',
|
||||
deviceLimitUnlimited: 'Без лимита устройств',
|
||||
status: {
|
||||
Active: 'Активен',
|
||||
Disabled: 'Отключён',
|
||||
@@ -283,6 +280,7 @@ const resources = {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
system: 'System',
|
||||
unlimited: 'Unlimited',
|
||||
|
||||
auth: {
|
||||
loginTitle: 'Log in',
|
||||
@@ -303,6 +301,8 @@ const resources = {
|
||||
passwordHint: 'At least 8 characters, with one uppercase letter and one digit.',
|
||||
or: 'or',
|
||||
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
|
||||
telegramQrHint:
|
||||
'Follow the link or scan the QR code — it opens the bot in Telegram. If this is your first time, tap "Start".',
|
||||
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
|
||||
telegramLoginRejected: 'Login was rejected in Telegram.',
|
||||
telegramLoginExpired: 'The request expired, please try again.',
|
||||
@@ -343,8 +343,6 @@ const resources = {
|
||||
selectLocation: 'Select location',
|
||||
location: 'Location',
|
||||
label: 'Label (optional)',
|
||||
deviceLimitLabel: 'Device limit (optional)',
|
||||
deviceLimitPlaceholder: 'Unlimited',
|
||||
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
|
||||
created: 'Config created.',
|
||||
quotaExceeded: 'Config quota reached for your role.',
|
||||
@@ -359,9 +357,6 @@ const resources = {
|
||||
subscriptionLink: 'Subscription link (for the client app):',
|
||||
aggregatedSubscription: 'Aggregated subscription',
|
||||
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
|
||||
deviceLimit: '{{count}} device',
|
||||
deviceLimit_other: '{{count}} devices',
|
||||
deviceLimitUnlimited: 'No device limit',
|
||||
status: {
|
||||
Active: 'Active',
|
||||
Disabled: 'Disabled',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from '@/shared/lib/cn'
|
||||
export const Dialog = DialogPrimitive.Root
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
export function DialogContent({ className, children, ...props }: DialogPrimitive.DialogContentProps) {
|
||||
export function DialogContent({ className, children, style, ...props }: DialogPrimitive.DialogContentProps) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60" />
|
||||
@@ -14,6 +14,12 @@ export function DialogContent({ className, children, ...props }: DialogPrimitive
|
||||
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
// Пока открыт вложенный Select/Popover, Radix ставит body { pointer-events: none },
|
||||
// из-за чего клик мимо попапа, но всё ещё в области диалога, проваливается сквозь
|
||||
// DialogContent на DialogOverlay (у него свой pointer-events: auto) — а Overlay
|
||||
// всегда закрывает диалог. Возвращаем контенту явный auto, чтобы клики по его
|
||||
// области не проваливались; попап при этом закрывается своей собственной логикой.
|
||||
style={{ pointerEvents: 'auto', ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user