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). (append-only, источник Web/Telegram/System).
- **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу. - **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу.
- **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот — read-only** по конфигам. - **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот — read-only** по конфигам.
- **Конфиг**: пользователь задаёт метку (`Label`) и лимит устройств (`DeviceLimit``limitIp` в 3x-ui, 0=без лимита), может редактировать. - **Конфиг**: пользователь задаёт метку (`Label`), может редактировать. Лимит устройств (`limitIp` в 3x-ui) панелью не управляется — более сложная настройка, задаётся при необходимости администратором ноды напрямую в 3x-ui.
- **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется. - **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется.
- **API без версионирования** (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`. - **API без версионирования** (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`.
- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage). - **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage).
@@ -44,14 +44,14 @@ public static class ConfigEndpoints
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken) 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); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken) 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); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); 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); public sealed record ConfigLinkResponseDto(string ConnectionString, string SubscriptionUrl);
@@ -36,7 +36,7 @@ public sealed class BlockUserCommandHandler(
{ {
var updateResult = await gateway.UpdateClientAsync( var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, 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) if (!updateResult.IsSuccess)
{ {
@@ -35,7 +35,7 @@ public sealed class UnblockUserCommandHandler(
{ {
var updateResult = await gateway.UpdateClientAsync( var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, 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) if (!updateResult.IsSuccess)
{ {
@@ -27,7 +27,7 @@ public interface IXuiPanelGateway
/// <summary>Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).</summary> /// <summary>Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).</summary>
Task<Result<string>> AddClientAsync( Task<Result<string>> AddClientAsync(
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
int deviceLimit, CancellationToken cancellationToken); CancellationToken cancellationToken);
Task<Result> RemoveClientAsync( Task<Result> RemoveClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
@@ -35,7 +35,7 @@ public interface IXuiPanelGateway
Task<Result> UpdateClientAsync( Task<Result> UpdateClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, 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( Task<Result<string>> BuildConnectionStringAsync(
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost,
@@ -3,4 +3,4 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Create; 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) if (node is null || !node.IsEnabled)
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled); 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); var reserveResult = await ReserveQuotaSlotAsync(userId, profile.MaxConfigs, config, cancellationToken);
if (!reserveResult.IsSuccess) if (!reserveResult.IsSuccess)
@@ -44,7 +44,7 @@ public sealed class CreateVpnConfigCommandHandler(
var addResult = await gateway.AddClientAsync( var addResult = await gateway.AddClientAsync(
node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail, node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail,
config.Label ?? config.ClientEmail, config.DeviceLimit, cancellationToken); config.Label ?? config.ClientEmail, cancellationToken);
if (!addResult.IsSuccess) if (!addResult.IsSuccess)
{ {
@@ -8,6 +8,5 @@ public sealed class CreateVpnConfigCommandValidator : AbstractValidator<CreateVp
{ {
RuleFor(x => x.InboundId).NotEmpty(); RuleFor(x => x.InboundId).NotEmpty();
RuleFor(x => x.Label).MaximumLength(100); 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; 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); return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAvailable);
if (command.Label is not null) 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); var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (node is not null) if (node is not null)
{ {
await gateway.UpdateClientAsync( await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, 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() public EditVpnConfigCommandValidator()
{ {
RuleFor(x => x.Label).MaximumLength(100); 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 newClientEmail = VpnConfig.GenerateClientEmail(userId);
var addResult = await gateway.AddClientAsync( var addResult = await gateway.AddClientAsync(
node, inbound.RemoteInboundId, config.Protocol, newClientEmail, node, inbound.RemoteInboundId, config.Protocol, newClientEmail,
config.Label ?? newClientEmail, config.DeviceLimit, cancellationToken); config.Label ?? newClientEmail, cancellationToken);
if (!addResult.IsSuccess) if (!addResult.IsSuccess)
return Result.Failure<VpnConfigDto>(addResult.Error); return Result.Failure<VpnConfigDto>(addResult.Error);
@@ -8,10 +8,10 @@ namespace PnvPanel.Application.Configs;
/// детали 3x-ui в этот DTO не попадают (см. domain-model.md). /// детали 3x-ui в этот DTO не попадают (см. domain-model.md).
/// </summary> /// </summary>
public sealed record VpnConfigDto( 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) long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt, ConfigStatus Status, DateTimeOffset CreatedAt)
{ {
public static VpnConfigDto FromDomain(VpnConfig config, Inbound inbound) => new( 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); 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 ClientEmail { get; private set; } = string.Empty;
public string ClientExternalId { get; private set; } = string.Empty; public string ClientExternalId { get; private set; } = string.Empty;
public VpnProtocol Protocol { get; private set; } public VpnProtocol Protocol { get; private set; }
public int DeviceLimit { get; private set; }
public long UsedUpBytes { get; private set; } public long UsedUpBytes { get; private set; }
public long UsedDownBytes { get; private set; } public long UsedDownBytes { get; private set; }
public DateTimeOffset? ExpiresAt { 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 return new VpnConfig
{ {
@@ -42,7 +41,6 @@ public sealed class VpnConfig : Entity
ClientEmail = GenerateClientEmail(userId), ClientEmail = GenerateClientEmail(userId),
ClientExternalId = string.Empty, ClientExternalId = string.Empty,
Label = label, Label = label,
DeviceLimit = deviceLimit,
Status = ConfigStatus.Active, Status = ConfigStatus.Active,
SubscriptionToken = GenerateToken(), SubscriptionToken = GenerateToken(),
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -54,8 +52,6 @@ public sealed class VpnConfig : Entity
public void Rename(string? label) => Label = label; public void Rename(string? label) => Label = label;
public void SetDeviceLimit(int deviceLimit) => DeviceLimit = deviceLimit;
public void Rotate(string newClientEmail, string newClientExternalId) public void Rotate(string newClientEmail, string newClientExternalId)
{ {
EnsureActive("перевыпустить"); 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") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<int>("DeviceLimit")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ExpiresAt") b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -71,12 +71,14 @@ internal sealed class XuiPanelGateway(
public async Task<Result<string>> AddClientAsync( public async Task<Result<string>> AddClientAsync(
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
int deviceLimit, CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
try try
{ {
var client = GetClient(node); 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); var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken);
return Result.Success(result.ExternalClientId); return Result.Success(result.ExternalClientId);
} }
@@ -103,12 +105,13 @@ internal sealed class XuiPanelGateway(
public async Task<Result> UpdateClientAsync( public async Task<Result> UpdateClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
string name, int deviceLimit, bool enable, CancellationToken cancellationToken) string name, bool enable, CancellationToken cancellationToken)
{ {
try try
{ {
var client = GetClient(node); 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); await client.UpdateClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), request, cancellationToken);
return Result.Success(); return Result.Success();
} }
@@ -36,7 +36,7 @@ public class BlockUserCommandHandlerTests
Assert.Equal(failure, result.Error); Assert.Equal(failure, result.Error);
await _gateway.DidNotReceive().UpdateClientAsync( await _gateway.DidNotReceive().UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), 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] [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 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 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.Nodes.Add(node);
dbContext.Inbounds.Add(inbound); dbContext.Inbounds.Add(inbound);
@@ -59,7 +59,7 @@ public class BlockUserCommandHandlerTests
_currentUser.UserId.Returns(adminId); _currentUser.UserId.Returns(adminId);
_gateway.UpdateClientAsync( _gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), 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()); .Returns(Result.Success());
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger);
@@ -71,7 +71,7 @@ public class BlockUserCommandHandlerTests
await _gateway.Received(1).UpdateClientAsync( await _gateway.Received(1).UpdateClientAsync(
Arg.Is<Node>(n => n.Id == node.Id), inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, 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>()); await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Disabled, Arg.Any<CancellationToken>());
var audit = Assert.Single(dbContext.AuditLogs.Local); var audit = Assert.Single(dbContext.AuditLogs.Local);
@@ -95,7 +95,7 @@ public class BlockUserCommandHandlerTests
Assert.True(result.IsSuccess); Assert.True(result.IsSuccess);
await _gateway.DidNotReceive().UpdateClientAsync( await _gateway.DidNotReceive().UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), 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] [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 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 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.Nodes.Add(node);
dbContext.Inbounds.Add(inbound); dbContext.Inbounds.Add(inbound);
@@ -118,7 +118,7 @@ public class BlockUserCommandHandlerTests
_currentUser.UserId.Returns(adminId); _currentUser.UserId.Returns(adminId);
_gateway.UpdateClientAsync( _gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), 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", "Нода недоступна."))); .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна.")));
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger); 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 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 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(); config.Disable();
dbContext.Nodes.Add(node); dbContext.Nodes.Add(node);
@@ -40,7 +40,7 @@ public class UnblockUserCommandHandlerTests
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_gateway.UpdateClientAsync( _gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), 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()); .Returns(Result.Success());
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger); var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger);
@@ -51,7 +51,7 @@ public class UnblockUserCommandHandlerTests
Assert.Equal(ConfigStatus.Active, config.Status); Assert.Equal(ConfigStatus.Active, config.Status);
await _gateway.Received(1).UpdateClientAsync( await _gateway.Received(1).UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, 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>()); await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Active, Arg.Any<CancellationToken>());
Assert.Single(dbContext.AuditLogs.Local); Assert.Single(dbContext.AuditLogs.Local);
Assert.Equal("UserUnblocked", dbContext.AuditLogs.Local.Single().Action); 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 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 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(); config.Disable();
dbContext.Nodes.Add(node); dbContext.Nodes.Add(node);
@@ -96,7 +96,7 @@ public class UnblockUserCommandHandlerTests
_identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.UnblockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_gateway.UpdateClientAsync( _gateway.UpdateClientAsync(
Arg.Any<Node>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<VpnProtocol>(), 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", "Нода недоступна."))); .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна.")));
var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger); 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); var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443);
inbound.Publish("My inbound", [], null); inbound.Publish("My inbound", [], null);
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active", 0); var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active");
var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked", 0); var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked");
revokedConfig.Revoke(); 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.Inbounds.Add(inbound);
dbContext.VpnConfigs.AddRange(activeConfig, revokedConfig, otherUsersConfig); 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 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 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"); config.AssignRemoteClient("external-id");
dbContext.Nodes.Add(node); dbContext.Nodes.Add(node);
@@ -49,7 +49,7 @@ public class RevokeVpnConfigCommandHandlerTests
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); 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(); config.Revoke();
dbContext.Inbounds.Add(inbound); 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 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 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"); config.AssignRemoteClient("old-external-id");
dbContext.Nodes.Add(node); dbContext.Nodes.Add(node);
@@ -34,7 +34,7 @@ public class RotateVpnConfigCommandHandlerTests
_gateway.AddClientAsync( _gateway.AddClientAsync(
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(), 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")); .Returns(Result.Success("new-external-id"));
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId)); var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
@@ -69,7 +69,7 @@ public class RotateVpnConfigCommandHandlerTests
var otherUserId = Guid.NewGuid(); var otherUserId = Guid.NewGuid();
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); 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.Inbounds.Add(inbound);
dbContext.VpnConfigs.Add(config); dbContext.VpnConfigs.Add(config);
@@ -90,7 +90,7 @@ public class RotateVpnConfigCommandHandlerTests
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); 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(); config.Revoke();
dbContext.Inbounds.Add(inbound); 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 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 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"); config.AssignRemoteClient("old-external-id");
dbContext.Nodes.Add(node); dbContext.Nodes.Add(node);
@@ -124,7 +124,7 @@ public class RotateVpnConfigCommandHandlerTests
var gatewayError = Error.Failure("Xui.Unreachable", "Панель недоступна."); var gatewayError = Error.Failure("Xui.Unreachable", "Панель недоступна.");
_gateway.AddClientAsync( _gateway.AddClientAsync(
Arg.Any<Node>(), inbound.RemoteInboundId, config.Protocol, Arg.Any<string>(), 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)); .Returns(Result.Failure<string>(gatewayError));
var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId)); var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId));
@@ -13,13 +13,12 @@ public class VpnConfigTests
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var inboundId = 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(userId, config.UserId);
Assert.Equal(inboundId, config.InboundId); Assert.Equal(inboundId, config.InboundId);
Assert.Equal(VpnProtocol.Vless, config.Protocol); Assert.Equal(VpnProtocol.Vless, config.Protocol);
Assert.Equal("My device", config.Label); Assert.Equal("My device", config.Label);
Assert.Equal(3, config.DeviceLimit);
Assert.Equal(ConfigStatus.Active, config.Status); Assert.Equal(ConfigStatus.Active, config.Status);
Assert.Equal(string.Empty, config.ClientExternalId); Assert.Equal(string.Empty, config.ClientExternalId);
Assert.False(string.IsNullOrWhiteSpace(config.ClientEmail)); Assert.False(string.IsNullOrWhiteSpace(config.ClientEmail));
@@ -32,8 +31,8 @@ public class VpnConfigTests
public void Create_GeneratesUniqueSubscriptionTokensAndClientEmails() public void Create_GeneratesUniqueSubscriptionTokensAndClientEmails()
{ {
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var a = 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, 1); var b = VpnConfig.Create(userId, Guid.NewGuid(), VpnProtocol.Vless, null);
Assert.NotEqual(a.SubscriptionToken, b.SubscriptionToken); Assert.NotEqual(a.SubscriptionToken, b.SubscriptionToken);
Assert.NotEqual(a.ClientEmail, b.ClientEmail); Assert.NotEqual(a.ClientEmail, b.ClientEmail);
@@ -42,7 +41,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void AssignRemoteClient_SetsClientExternalId() 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"); config.AssignRemoteClient("some-remote-password");
@@ -52,7 +51,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void Rotate_WhenActive_ChangesEmailExternalIdAndToken() 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"); config.AssignRemoteClient("old-id");
var oldToken = config.SubscriptionToken; var oldToken = config.SubscriptionToken;
var oldEmail = config.ClientEmail; var oldEmail = config.ClientEmail;
@@ -70,7 +69,7 @@ public class VpnConfigTests
[InlineData(ConfigStatus.Disabled)] [InlineData(ConfigStatus.Disabled)]
public void Rotate_WhenNotActive_Throws(ConfigStatus status) 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); MoveToStatus(config, status);
Assert.Throws<DomainException>(() => config.Rotate("e", "i")); Assert.Throws<DomainException>(() => config.Rotate("e", "i"));
@@ -79,7 +78,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void Revoke_WhenActive_SetsRevokedStatus() 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(); config.Revoke();
@@ -89,7 +88,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void Revoke_WhenAlreadyRevoked_Throws() 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(); config.Revoke();
Assert.Throws<DomainException>(() => config.Revoke()); Assert.Throws<DomainException>(() => config.Revoke());
@@ -98,7 +97,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void Disable_WhenActive_SetsDisabled() 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(); config.Disable();
@@ -108,7 +107,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void Disable_WhenRevoked_DoesNotChangeStatus() 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.Revoke();
config.Disable(); config.Disable();
@@ -119,7 +118,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void Enable_WhenDisabled_ReturnsToActive() 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.Disable();
config.Enable(); config.Enable();
@@ -130,7 +129,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void Enable_WhenRevoked_DoesNotResurrect() 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.Revoke();
config.Enable(); config.Enable();
@@ -141,7 +140,7 @@ public class VpnConfigTests
[Fact] [Fact]
public void UpdateTraffic_SetsBytesAndLastSyncAt() 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); config.UpdateTraffic(100, 200);
@@ -87,7 +87,6 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
{ {
inboundId = inbound.Id, inboundId = inbound.Id,
label = $"device-{i}", label = $"device-{i}",
deviceLimit = (int?)null,
}); });
}); });
@@ -32,7 +32,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway
public Task<Result<string>> AddClientAsync( public Task<Result<string>> AddClientAsync(
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
int deviceLimit, CancellationToken cancellationToken) CancellationToken cancellationToken)
=> Task.FromResult(Result.Success(Guid.NewGuid().ToString())); => Task.FromResult(Result.Success(Guid.NewGuid().ToString()));
public Task<Result> RemoveClientAsync( public Task<Result> RemoveClientAsync(
@@ -41,7 +41,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway
public Task<Result> UpdateClientAsync( public Task<Result> UpdateClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, 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()); => Task.FromResult(Result.Success());
public Task<Result<string>> BuildConnectionStringAsync( 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/inbounds/available` | — | `AvailableInboundDto[]` |
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }`**без пагинации**, весь список сразу | | GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }`**без пагинации**, весь список сразу |
| POST | `/api/configs` | `{ inboundId, label?, deviceLimit? }`| `VpnConfigDto` (`200 OK`, не 201) | | POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) |
| PATCH | `/api/configs/{id}` | `{ label?, deviceLimit? }` | `VpnConfigDto` | | PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` |
| POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) | | POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) |
| DELETE | `/api/configs/{id}` | — | `204 No Content` | | DELETE | `/api/configs/{id}` | — | `204 No Content` |
| GET | `/api/configs/{id}/link` | — | `{ connectionString, subscriptionUrl }` | | GET | `/api/configs/{id}/link` | — | `{ connectionString, subscriptionUrl }` |
| GET | `/api/subscription` | — | `{ subscriptionUrl }` | | GET | `/api/subscription` | — | `{ subscriptionUrl }` |
**Нет отдельного `GET /api/configs/{id}`** — детали конфига берутся из списка `GET /api/configs`. **Нет отдельного `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` (лимиты по сроку не реализованы — см. status, createdAt }`. `expiresAt` всегда `null` (лимиты по сроку не реализованы — см.
[domain-model.md](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт [domain-model.md](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт
запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из
+2 -2
View File
@@ -132,7 +132,7 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
``` ```
POST /api/configs POST /api/configs
→ CreateVpnConfigCommand → CreateVpnConfigCommand
→ ValidationBehavior (FluentValidation — формат inboundId/label/deviceLimit) → ValidationBehavior (FluentValidation — формат inboundId/label)
→ CreateVpnConfigCommandHandler → CreateVpnConfigCommandHandler
· проверяет активацию + роль инбаунда (доменные проверки) · проверяет активацию + роль инбаунда (доменные проверки)
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания · SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
@@ -141,7 +141,7 @@ POST /api/configs
· VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext · VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
· при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync) · при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
→ UnitOfWorkBehavior (commit транзакции) → UnitOfWorkBehavior (commit транзакции)
→ 200 OK VpnConfigDto { id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes, → 200 OK VpnConfigDto { id, label, protocol, location, usedUpBytes, usedDownBytes,
expiresAt, status, createdAt } expiresAt, status, createdAt }
``` ```
Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно, Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно,
+2 -2
View File
@@ -80,7 +80,6 @@ ClientApp (каталог приложений-клиен
| `ClientEmail` | `string` | Уникальный ключ клиента в 3x-ui; схема `pnv_{userIdShort}_{rand}` (уникален в рамках панели, виден владелец) | | `ClientEmail` | `string` | Уникальный ключ клиента в 3x-ui; схема `pnv_{userIdShort}_{rand}` (уникален в рамках панели, виден владелец) |
| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) | | `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) |
| `Protocol` | `VpnProtocol` | Денормализовано с inbound | | `Protocol` | `VpnProtocol` | Денормализовано с inbound |
| `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui |
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) | | `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui | | `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
| `ExpiresAt` | `DateTimeOffset?`| Зарезервировано, сейчас ничего его не выставляет — конфиг живёт бессрочно | | `ExpiresAt` | `DateTimeOffset?`| Зарезервировано, сейчас ничего его не выставляет — конфиг живёт бессрочно |
@@ -100,7 +99,8 @@ ClientApp (каталог приложений-клиен
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки. удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение - `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера). самого клиента в 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` при периодической синхронизации, только для отображения. - `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`). - **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`; - Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
+5 -3
View File
@@ -87,10 +87,12 @@ frontend/
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику, трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
неактивированного — `ActivationGate` вместо дашборда. неактивированного — `ActivationGate` вместо дашборда.
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка + лимит устройств. - **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка. Лимит устройств
После успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно. (`limitIp` в 3x-ui) панелью не управляется — задаётся при необходимости напрямую в 3x-ui. После
успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`), - **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
сгруппированный по ОС; клик по приложению открывает ссылку на скачивание. сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает
ссылку на скачивание.
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`), - **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
удаление аккаунта с подтверждением (`DeleteAccountSection`). удаление аккаунта с подтверждением (`DeleteAccountSection`).
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации, - **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
+1 -1
View File
@@ -92,8 +92,8 @@
| Подписка | Агрегированная на юзера (`AppUser.SubscriptionToken`) + по конфигу | | Подписка | Агрегированная на юзера (`AppUser.SubscriptionToken`) + по конфигу |
| Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды | | Аудит | `AuditLog` (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды |
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) | | Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
| Лимит устройств | Per-config, задаёт юзер (`DeviceLimit``limitIp` в 3x-ui; 0 = без лимита) |
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») | | Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
| Лимит устройств (`limitIp`) | Панелью не управляется — задаётся при необходимости напрямую в 3x-ui администратором ноды |
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` | | Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
| Версионирование API | Без версий (`/api` без `v1`) | | Версионирование API | Без версий (`/api` без `v1`) |
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` | | Подписка (заголовки) | `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 { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Download } from 'lucide-react' import { Download } from 'lucide-react'
import { Card, CardHeader, CardTitle } from '@/shared/ui/card' import { Card, CardHeader, CardTitle } from '@/shared/ui/card'
import { cn } from '@/shared/lib/cn'
import type { OsPlatform } from '@/shared/api/types' import type { OsPlatform } from '@/shared/api/types'
import { listApps } from './api' import { listApps } from './api'
@@ -10,19 +12,43 @@ const OS_ORDER: OsPlatform[] = ['IOS', 'Android', 'Windows', 'MacOS', 'Linux']
export function AppsCatalog() { export function AppsCatalog() {
const { t } = useTranslation() const { t } = useTranslation()
const { data, isLoading } = useQuery({ queryKey: ['client-apps'], queryFn: listApps }) 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 (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> return <p className="text-sm text-muted-foreground">{t('instructions.noApps')}</p>
} }
const apps = activeOs ? data[activeOs] ?? [] : []
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4">
{OS_ORDER.filter((os) => data[os] && data[os]!.length > 0).map((os) => ( <nav className="flex gap-1 overflow-x-auto border-b border-border">
<div key={os} className="flex flex-col gap-3"> {availableOs.map((os) => (
<h3 className="text-sm font-semibold text-muted-foreground">{t(`instructions.os.${os}`)}</h3> <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"> <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"> <a key={app.id} href={app.downloadUrl} target="_blank" rel="noreferrer">
<Card className="transition-colors hover:bg-muted"> <Card className="transition-colors hover:bg-muted">
<CardHeader className="flex-row items-center gap-3 space-y-0"> <CardHeader className="flex-row items-center gap-3 space-y-0">
@@ -41,7 +67,5 @@ export function AppsCatalog() {
))} ))}
</div> </div>
</div> </div>
))}
</div>
) )
} }
+1 -4
View File
@@ -77,11 +77,8 @@ export function ConfigCard({ config }: { config: VpnConfigDto }) {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<div className="flex justify-between text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
<span>
{formatBytes(config.usedUpBytes)} {formatBytes(config.usedDownBytes)} {formatBytes(config.usedUpBytes)} {formatBytes(config.usedDownBytes)}
</span>
<span>{config.deviceLimit > 0 ? t('configs.deviceLimit', { count: config.deviceLimit }) : t('configs.deviceLimitUnlimited')}</span>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => setDetailsOpen(true)}> <Button size="sm" variant="outline" onClick={() => setDetailsOpen(true)}>
@@ -18,17 +18,15 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [inboundId, setInboundId] = useState('') const [inboundId, setInboundId] = useState('')
const [label, setLabel] = useState('') const [label, setLabel] = useState('')
const [deviceLimit, setDeviceLimit] = useState('')
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => createConfig(inboundId, label.trim() || undefined, deviceLimit ? Number(deviceLimit) : undefined), mutationFn: () => createConfig(inboundId, label.trim() || undefined),
onSuccess: async () => { onSuccess: async () => {
toast.success(t('configs.created')) toast.success(t('configs.created'))
await queryClient.invalidateQueries({ queryKey: ['my-configs'] }) await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
setOpen(false) setOpen(false)
setInboundId('') setInboundId('')
setLabel('') setLabel('')
setDeviceLimit('')
}, },
onError: (error) => { onError: (error) => {
const message = const message =
@@ -75,17 +73,6 @@ export function CreateConfigDialog({ inbounds }: { inbounds: AvailableInboundDto
<Label htmlFor="label">{t('configs.label')}</Label> <Label htmlFor="label">{t('configs.label')}</Label>
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} /> <Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} />
</div> </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}> <Button type="submit" disabled={!inboundId || createMutation.isPending}>
{t('configs.create')} {t('configs.create')}
</Button> </Button>
+4 -4
View File
@@ -15,17 +15,17 @@ export function getMyConfigs() {
return apiRequest<GetMyConfigsResult>('/configs') 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', { return apiRequest<VpnConfigDto>('/configs', {
method: 'POST', 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}`, { return apiRequest<VpnConfigDto>(`/configs/${id}`, {
method: 'PATCH', method: 'PATCH',
body: { label: label ?? null, deviceLimit: deviceLimit ?? null }, body: { label: label ?? null },
}) })
} }
@@ -35,11 +35,11 @@ export function TelegramLinkCard() {
}) })
useEffect(() => { useEffect(() => {
if (!meQuery.data?.telegramLinked) return if (!open || !meQuery.data?.telegramLinked) return
setUser(meQuery.data) setUser(meQuery.data)
setOpen(false) setOpen(false)
toast.success(t('settings.telegramLinked')) toast.success(t('settings.telegramLinked'))
}, [meQuery.data, setUser, t]) }, [open, meQuery.data, setUser, t])
const unlinkMutation = useMutation({ const unlinkMutation = useMutation({
mutationFn: unlinkTelegram, mutationFn: unlinkTelegram,
@@ -91,6 +91,7 @@ export function TelegramLinkCard() {
<a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline"> <a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
{deepLink} {deepLink}
</a> </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> <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"> <a href={deepLink} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline">
{deepLink} {deepLink}
</a> </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>} {!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: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--font-sans: system-ui, 'Segoe UI', Roboto, sans-serif; --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 { @layer base {
+1 -1
View File
@@ -61,7 +61,7 @@ function AdminRolesPage() {
<td className="py-2"> <td className="py-2">
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>} {role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
</td> </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"> <td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(role)}> <Button size="sm" variant="outline" onClick={() => setEditing(role)}>
{t('admin.roles.edit')} {t('admin.roles.edit')}
-6
View File
@@ -1849,8 +1849,6 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
inboundId: string; inboundId: string;
label: null | string; label: null | string;
/** Format: int32 */
deviceLimit: null | number | string;
}; };
CreateRoleCommand: { CreateRoleCommand: {
name: string; name: string;
@@ -1867,8 +1865,6 @@ export interface components {
}; };
EditConfigBody: { EditConfigBody: {
label: null | string; label: null | string;
/** Format: int32 */
deviceLimit: null | number | string;
}; };
GetMyConfigsResult: { GetMyConfigsResult: {
configs: components["schemas"]["VpnConfigDto"][]; configs: components["schemas"]["VpnConfigDto"][];
@@ -2071,8 +2067,6 @@ export interface components {
label: null | string; label: null | string;
protocol: components["schemas"]["VpnProtocol"]; protocol: components["schemas"]["VpnProtocol"];
location: string; location: string;
/** Format: int32 */
deviceLimit: number | string;
/** Format: int64 */ /** Format: int64 */
usedUpBytes: number | string; usedUpBytes: number | string;
/** Format: int64 */ /** Format: int64 */
-1
View File
@@ -51,7 +51,6 @@ export type VpnConfigDto = {
label: string | null label: string | null
protocol: VpnProtocol protocol: VpnProtocol
location: string location: string
deviceLimit: number
usedUpBytes: number usedUpBytes: number
usedDownBytes: number usedDownBytes: number
expiresAt: string | null expiresAt: string | null
+6 -11
View File
@@ -11,6 +11,7 @@ const resources = {
light: 'Светлая', light: 'Светлая',
dark: 'Тёмная', dark: 'Тёмная',
system: 'Системная', system: 'Системная',
unlimited: 'Без лимита',
auth: { auth: {
loginTitle: 'Вход', loginTitle: 'Вход',
@@ -31,6 +32,8 @@ const resources = {
passwordHint: 'Не менее 8 символов, минимум одна заглавная буква и одна цифра.', passwordHint: 'Не менее 8 символов, минимум одна заглавная буква и одна цифра.',
or: 'или', or: 'или',
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.', telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
telegramQrHint:
'Перейдите по ссылке или отсканируйте QR-код — откроется бот в Telegram. Если открываете его впервые, нажмите «Старт».',
waitingForConfirmation: 'Ожидание подтверждения в Telegram…', waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
telegramLoginRejected: 'Вход отклонён в Telegram.', telegramLoginRejected: 'Вход отклонён в Telegram.',
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.', telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
@@ -71,8 +74,6 @@ const resources = {
selectLocation: 'Выберите локацию', selectLocation: 'Выберите локацию',
location: 'Локация', location: 'Локация',
label: 'Метка (необязательно)', label: 'Метка (необязательно)',
deviceLimitLabel: 'Лимит устройств (необязательно)',
deviceLimitPlaceholder: 'Без лимита',
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.', noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
created: 'Конфиг создан.', created: 'Конфиг создан.',
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.', quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
@@ -87,10 +88,6 @@ const resources = {
subscriptionLink: 'Ссылка-подписка (для клиента):', subscriptionLink: 'Ссылка-подписка (для клиента):',
aggregatedSubscription: 'Общая подписка', aggregatedSubscription: 'Общая подписка',
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.', aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
deviceLimit: '{{count}} устройство',
deviceLimit_few: '{{count}} устройства',
deviceLimit_many: '{{count}} устройств',
deviceLimitUnlimited: 'Без лимита устройств',
status: { status: {
Active: 'Активен', Active: 'Активен',
Disabled: 'Отключён', Disabled: 'Отключён',
@@ -283,6 +280,7 @@ const resources = {
light: 'Light', light: 'Light',
dark: 'Dark', dark: 'Dark',
system: 'System', system: 'System',
unlimited: 'Unlimited',
auth: { auth: {
loginTitle: 'Log in', loginTitle: 'Log in',
@@ -303,6 +301,8 @@ const resources = {
passwordHint: 'At least 8 characters, with one uppercase letter and one digit.', passwordHint: 'At least 8 characters, with one uppercase letter and one digit.',
or: 'or', or: 'or',
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.', 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…', waitingForConfirmation: 'Waiting for confirmation in Telegram…',
telegramLoginRejected: 'Login was rejected in Telegram.', telegramLoginRejected: 'Login was rejected in Telegram.',
telegramLoginExpired: 'The request expired, please try again.', telegramLoginExpired: 'The request expired, please try again.',
@@ -343,8 +343,6 @@ const resources = {
selectLocation: 'Select location', selectLocation: 'Select location',
location: 'Location', location: 'Location',
label: 'Label (optional)', 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.', noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
created: 'Config created.', created: 'Config created.',
quotaExceeded: 'Config quota reached for your role.', quotaExceeded: 'Config quota reached for your role.',
@@ -359,9 +357,6 @@ const resources = {
subscriptionLink: 'Subscription link (for the client app):', subscriptionLink: 'Subscription link (for the client app):',
aggregatedSubscription: 'Aggregated subscription', aggregatedSubscription: 'Aggregated subscription',
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.', 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: { status: {
Active: 'Active', Active: 'Active',
Disabled: 'Disabled', Disabled: 'Disabled',
+7 -1
View File
@@ -5,7 +5,7 @@ import { cn } from '@/shared/lib/cn'
export const Dialog = DialogPrimitive.Root export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger export const DialogTrigger = DialogPrimitive.Trigger
export function DialogContent({ className, children, ...props }: DialogPrimitive.DialogContentProps) { export function DialogContent({ className, children, style, ...props }: DialogPrimitive.DialogContentProps) {
return ( return (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60" /> <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', '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, className,
)} )}
// Пока открыт вложенный Select/Popover, Radix ставит body { pointer-events: none },
// из-за чего клик мимо попапа, но всё ещё в области диалога, проваливается сквозь
// DialogContent на DialogOverlay (у него свой pointer-events: auto) — а Overlay
// всегда закрывает диалог. Возвращаем контенту явный auto, чтобы клики по его
// области не проваливались; попап при этом закрывается своей собственной логикой.
style={{ pointerEvents: 'auto', ...style }}
{...props} {...props}
> >
{children} {children}