From bea2b5fcf739fd05704898c32cbfd3599275d596 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 2 Jul 2026 23:21:26 +0300 Subject: [PATCH] Refactor VPN configuration handling to remove device limit management - Updated the VPN configuration commands and handlers to eliminate the device limit parameter, simplifying the configuration process. - Adjusted related API documentation to reflect the removal of device limit management, clarifying that this setting is now handled directly in the 3x-ui by node administrators. - Enhanced the overall codebase by removing unnecessary device limit references across various components, ensuring a cleaner and more maintainable code structure. --- CLAUDE.md | 2 +- .../PnvPanel.Api/Endpoints/ConfigEndpoints.cs | 8 +- .../Admin/Users/BlockUserCommandHandler.cs | 2 +- .../Admin/Users/UnblockUserCommandHandler.cs | 2 +- .../Common/Interfaces/IXuiPanelGateway.cs | 4 +- .../Configs/Create/CreateVpnConfigCommand.cs | 2 +- .../Create/CreateVpnConfigCommandHandler.cs | 4 +- .../Create/CreateVpnConfigCommandValidator.cs | 1 - .../Configs/Edit/EditVpnConfigCommand.cs | 2 +- .../Edit/EditVpnConfigCommandHandler.cs | 7 +- .../Edit/EditVpnConfigCommandValidator.cs | 1 - .../Rotate/RotateVpnConfigCommandHandler.cs | 2 +- .../Configs/VpnConfigDto.cs | 4 +- .../src/PnvPanel.Domain/Configs/VpnConfig.cs | 6 +- ...260702201236_RemoveDeviceLimit.Designer.cs | 744 ++++++++++++++++++ .../20260702201236_RemoveDeviceLimit.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 3 - .../Xui/XuiPanelGateway.cs | 11 +- .../Users/BlockUserCommandHandlerTests.cs | 14 +- .../Users/UnblockUserCommandHandlerTests.cs | 10 +- .../GetMyConfigsQueryHandlerTests.cs | 6 +- .../RevokeVpnConfigCommandHandlerTests.cs | 4 +- .../RotateVpnConfigCommandHandlerTests.cs | 12 +- .../Configs/VpnConfigTests.cs | 27 +- .../Configs/ConfigQuotaTests.cs | 1 - .../TestSupport/FakeXuiPanelGateway.cs | 4 +- docs/api-design.md | 6 +- docs/architecture.md | 4 +- docs/domain-model.md | 4 +- docs/frontend.md | 8 +- docs/tech-stack.md | 2 +- frontend/src/features/apps/AppsCatalog.tsx | 76 +- frontend/src/features/configs/ConfigCard.tsx | 7 +- .../features/configs/CreateConfigDialog.tsx | 15 +- frontend/src/features/configs/api.ts | 8 +- .../features/settings/TelegramLinkCard.tsx | 5 +- .../features/telegram/TelegramLoginButton.tsx | 1 + frontend/src/index.css | 8 + frontend/src/routes/admin/roles.tsx | 2 +- frontend/src/shared/api/schema.gen.ts | 6 - frontend/src/shared/api/types.ts | 1 - frontend/src/shared/lib/i18n.ts | 17 +- frontend/src/shared/ui/dialog.tsx | 8 +- 43 files changed, 933 insertions(+), 157 deletions(-) create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.cs diff --git a/CLAUDE.md b/CLAUDE.md index 7324ac1..f422b73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs index 9da2d1e..e6f5384 100644 --- a/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs @@ -44,14 +44,14 @@ public static class ConfigEndpoints private static async Task 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 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); diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs index 3a753bc..18223ca 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs @@ -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) { diff --git a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs index 987eeeb..1e359c7 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs @@ -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) { diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs index 23bb164..f769eb4 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs @@ -27,7 +27,7 @@ public interface IXuiPanelGateway /// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). Task> AddClientAsync( Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, - int deviceLimit, CancellationToken cancellationToken); + CancellationToken cancellationToken); Task RemoveClientAsync( Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, @@ -35,7 +35,7 @@ public interface IXuiPanelGateway Task UpdateClientAsync( Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, - string name, int deviceLimit, bool enable, CancellationToken cancellationToken); + string name, bool enable, CancellationToken cancellationToken); Task> BuildConnectionStringAsync( Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs index ae2bf6c..47ea7f4 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs @@ -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>; +public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index 4b4e6d3..156582b 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -36,7 +36,7 @@ public sealed class CreateVpnConfigCommandHandler( if (node is null || !node.IsEnabled) return Result.Failure(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) { diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs index 2b62b36..7db44a5 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs @@ -8,6 +8,5 @@ public sealed class CreateVpnConfigCommandValidator : AbstractValidator x.InboundId).NotEmpty(); RuleFor(x => x.Label).MaximumLength(100); - RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue); } } diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs index 0a44716..d507c36 100644 --- a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs @@ -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>; +public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs index 4a00e6b..a4e5407 100644 --- a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs @@ -25,18 +25,15 @@ public sealed class EditVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPan return Result.Failure(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); } } diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs index 48d5735..0a69c8c 100644 --- a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs @@ -7,6 +7,5 @@ public sealed class EditVpnConfigCommandValidator : AbstractValidator x.Label).MaximumLength(100); - RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue); } } diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs index d3aa912..1a6fc1e 100644 --- a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs @@ -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(addResult.Error); diff --git a/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs index 05dec67..f6233be 100644 --- a/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs +++ b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs @@ -8,10 +8,10 @@ namespace PnvPanel.Application.Configs; /// детали 3x-ui в этот DTO не попадают (см. domain-model.md). /// 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); } diff --git a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs index 93ca401..d521221 100644 --- a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs +++ b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs @@ -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("перевыпустить"); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.Designer.cs new file mode 100644 index 0000000..468b005 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.Designer.cs @@ -0,0 +1,744 @@ +// +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 + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("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 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.cs new file mode 100644 index 0000000..141bf55 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260702201236_RemoveDeviceLimit.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveDeviceLimit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeviceLimit", + table: "VpnConfigs"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeviceLimit", + table: "VpnConfigs", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index cb12a07..78347c0 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -293,9 +293,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("DeviceLimit") - .HasColumnType("integer"); - b.Property("ExpiresAt") .HasColumnType("timestamp with time zone"); diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs index afbf834..a7a81b2 100644 --- a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -71,12 +71,14 @@ internal sealed class XuiPanelGateway( public async Task> 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 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(); } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs index ff761f7..cc64ba5 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs @@ -36,7 +36,7 @@ public class BlockUserCommandHandlerTests Assert.Equal(failure, result.Error); await _gateway.DidNotReceive().UpdateClientAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any()); } [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(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .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(n => n.Id == node.Id), inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, - "my-config", config.DeviceLimit, enable: false, Arg.Any()); + "my-config", enable: false, Arg.Any()); await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Disabled, Arg.Any()); var audit = Assert.Single(dbContext.AuditLogs.Local); @@ -95,7 +95,7 @@ public class BlockUserCommandHandlerTests Assert.True(result.IsSuccess); await _gateway.DidNotReceive().UpdateClientAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any()); } [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(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна."))); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs index 49a87a6..5a56b01 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs @@ -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()).Returns(Result.Success()); _gateway.UpdateClientAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .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(), config.DeviceLimit, true, Arg.Any()); + Arg.Any(), true, Arg.Any()); await _notifier.Received(1).NotifyConfigStatusChangedAsync(userId, config.Id, ConfigStatus.Active, Arg.Any()); 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()).Returns(Result.Success()); _gateway.UpdateClientAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна."))); var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs index 8aacc62..a4d74cf 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs @@ -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); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs index 0056c89..bff99bb 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs @@ -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); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs index 785f46f..dcf401d 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -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(), inbound.RemoteInboundId, config.Protocol, Arg.Any(), - Arg.Any(), config.DeviceLimit, Arg.Any()) + Arg.Any(), Arg.Any()) .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(), inbound.RemoteInboundId, config.Protocol, Arg.Any(), - Arg.Any(), config.DeviceLimit, Arg.Any()) + Arg.Any(), Arg.Any()) .Returns(Result.Failure(gatewayError)); var handler = new RotateVpnConfigCommandHandler(dbContext, _gateway, FakeCurrentUser.Authenticated(userId)); diff --git a/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs b/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs index 388cf10..cbbb636 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs @@ -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(() => 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(() => 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); diff --git a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs index 7b3e994..a4c6071 100644 --- a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs +++ b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs @@ -87,7 +87,6 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) { inboundId = inbound.Id, label = $"device-{i}", - deviceLimit = (int?)null, }); }); diff --git a/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs index f6b7e69..3dd0fdb 100644 --- a/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs +++ b/backend/tests/PnvPanel.IntegrationTests/TestSupport/FakeXuiPanelGateway.cs @@ -32,7 +32,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway public Task> 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 RemoveClientAsync( @@ -41,7 +41,7 @@ public sealed class FakeXuiPanelGateway : IXuiPanelGateway public Task 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> BuildConnectionStringAsync( diff --git a/docs/api-design.md b/docs/api-design.md index aac6075..54640eb 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -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 строится на фронте из diff --git a/docs/architecture.md b/docs/architecture.md index 063448a..56ce97c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 } ``` Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно, diff --git a/docs/domain-model.md b/docs/domain-model.md index 65c3b27..2652ed5 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -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`; diff --git a/docs/frontend.md b/docs/frontend.md index fe190a4..713ca9a 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -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/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации, diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 497c1e7..136556e 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -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` | diff --git a/frontend/src/features/apps/AppsCatalog.tsx b/frontend/src/features/apps/AppsCatalog.tsx index 0bcd649..9796a7a 100644 --- a/frontend/src/features/apps/AppsCatalog.tsx +++ b/frontend/src/features/apps/AppsCatalog.tsx @@ -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,38 +12,60 @@ 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(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

{t('instructions.noApps')}

} + const apps = activeOs ? data[activeOs] ?? [] : [] + return ( -
- {OS_ORDER.filter((os) => data[os] && data[os]!.length > 0).map((os) => ( -
-

{t(`instructions.os.${os}`)}

- -
- ))} +
+ +
) } diff --git a/frontend/src/features/configs/ConfigCard.tsx b/frontend/src/features/configs/ConfigCard.tsx index 9603d14..d7a63a8 100644 --- a/frontend/src/features/configs/ConfigCard.tsx +++ b/frontend/src/features/configs/ConfigCard.tsx @@ -77,11 +77,8 @@ export function ConfigCard({ config }: { config: VpnConfigDto }) {
-
- - ↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)} - - {config.deviceLimit > 0 ? t('configs.deviceLimit', { count: config.deviceLimit }) : t('configs.deviceLimitUnlimited')} +
+ ↑ {formatBytes(config.usedUpBytes)} ↓ {formatBytes(config.usedDownBytes)}
-
- - setDeviceLimit(e.target.value)} - /> -
diff --git a/frontend/src/features/configs/api.ts b/frontend/src/features/configs/api.ts index 96825a4..e24fcbb 100644 --- a/frontend/src/features/configs/api.ts +++ b/frontend/src/features/configs/api.ts @@ -15,17 +15,17 @@ export function getMyConfigs() { return apiRequest('/configs') } -export function createConfig(inboundId: string, label: string | undefined, deviceLimit: number | undefined) { +export function createConfig(inboundId: string, label: string | undefined) { return apiRequest('/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(`/configs/${id}`, { method: 'PATCH', - body: { label: label ?? null, deviceLimit: deviceLimit ?? null }, + body: { label: label ?? null }, }) } diff --git a/frontend/src/features/settings/TelegramLinkCard.tsx b/frontend/src/features/settings/TelegramLinkCard.tsx index 4ec8a7e..9efc856 100644 --- a/frontend/src/features/settings/TelegramLinkCard.tsx +++ b/frontend/src/features/settings/TelegramLinkCard.tsx @@ -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() { {deepLink} +

{t('auth.telegramQrHint')}

) : (

{t('auth.telegramBotNotConfigured')}

diff --git a/frontend/src/features/telegram/TelegramLoginButton.tsx b/frontend/src/features/telegram/TelegramLoginButton.tsx index 446b63c..2cacb04 100644 --- a/frontend/src/features/telegram/TelegramLoginButton.tsx +++ b/frontend/src/features/telegram/TelegramLoginButton.tsx @@ -72,6 +72,7 @@ export function TelegramLoginButton() { {deepLink} +

{t('auth.telegramQrHint')}

)} {!deepLink &&

{t('auth.telegramBotNotConfigured')}

} diff --git a/frontend/src/index.css b/frontend/src/index.css index 79596e7..4f39dc0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index a97b6cb..942057f 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -61,7 +61,7 @@ function AdminRolesPage() { {role.name} {role.isSystem && {t('admin.roles.system')}} - {role.maxConfigs < 0 ? t('configs.deviceLimitUnlimited') : role.maxConfigs} + {role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}