Refactor VPN configuration handling to remove device limit management
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s

- 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:
Leonid Pershin
2026-07-02 23:21:26 +03:00
parent 05d49a8cbd
commit bea2b5fcf7
43 changed files with 933 additions and 157 deletions
+1 -1
View File
@@ -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("перевыпустить");
@@ -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
}
}
}
@@ -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);
}
}
}
@@ -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();
}
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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
View File
@@ -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 строится на фронте из
+2 -2
View File
@@ -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 }
```
Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно,
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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` |
+32 -8
View File
@@ -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>
)
}
+1 -4
View File
@@ -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>
+4 -4
View File
@@ -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>}
+8
View File
@@ -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 {
+1 -1
View File
@@ -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')}
-6
View File
@@ -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 */
-1
View File
@@ -51,7 +51,6 @@ export type VpnConfigDto = {
label: string | null
protocol: VpnProtocol
location: string
deviceLimit: number
usedUpBytes: number
usedDownBytes: number
expiresAt: string | null
+6 -11
View File
@@ -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',
+7 -1
View File
@@ -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}