Enhance app management with 'IsRecommended' feature
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- Added 'IsRecommended' property to app-related data models, allowing apps to be marked as recommended.
- Updated API endpoints for creating and updating apps to include 'IsRecommended' in request bodies.
- Modified database schema to accommodate the new 'IsRecommended' field.
- Enhanced frontend components to display recommended apps with a star icon and updated forms to manage this property.
- Improved sorting logic in app listings to prioritize recommended apps.
- Updated documentation to reflect changes in API and data models.
This commit is contained in:
Leonid Pershin
2026-07-14 19:19:43 +03:00
parent 701c3a1d51
commit 8c53fcded2
24 changed files with 1154 additions and 31 deletions
@@ -53,7 +53,8 @@ public static class AdminAppEndpoints
body.Description,
body.IconUrl,
body.SortOrder,
body.IsEnabled
body.IsEnabled,
body.IsRecommended
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
@@ -77,5 +78,6 @@ public sealed record UpdateAppBody(
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
bool IsEnabled,
bool IsRecommended
);
@@ -10,7 +10,8 @@ public sealed record AdminAppDto(
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
bool IsEnabled,
bool IsRecommended
)
{
public static AdminAppDto FromDomain(ClientApp app) =>
@@ -22,6 +23,7 @@ public sealed record AdminAppDto(
app.Description,
app.IconUrl,
app.SortOrder,
app.IsEnabled
app.IsEnabled,
app.IsRecommended
);
}
@@ -10,5 +10,6 @@ public sealed record CreateAppCommand(
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder
int SortOrder,
bool IsRecommended
) : ICommand<Result<AdminAppDto>>;
@@ -19,7 +19,8 @@ public sealed class CreateAppCommandHandler(IAppDbContext dbContext)
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder
command.SortOrder,
command.IsRecommended
);
dbContext.ClientApps.Add(app);
@@ -12,5 +12,6 @@ public sealed record UpdateAppCommand(
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
bool IsEnabled,
bool IsRecommended
) : ICommand<Result<AdminAppDto>>;
@@ -27,7 +27,8 @@ public sealed class UpdateAppCommandHandler(IAppDbContext dbContext)
command.Description,
command.IconUrl,
command.SortOrder,
command.IsEnabled
command.IsEnabled,
command.IsRecommended
);
return Result.Success(AdminAppDto.FromDomain(app));
@@ -7,9 +7,17 @@ public sealed record ClientAppDto(
string Name,
string DownloadUrl,
string? Description,
string? IconUrl
string? IconUrl,
bool IsRecommended
)
{
public static ClientAppDto FromDomain(ClientApp app) =>
new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl);
new(
app.Id,
app.Name,
app.DownloadUrl.ToString(),
app.Description,
app.IconUrl,
app.IsRecommended
);
}
@@ -20,7 +20,8 @@ public sealed class ListAppsQueryHandler(IAppDbContext dbContext)
var apps = await dbContext
.ClientApps.AsNoTracking()
.Where(a => a.IsEnabled)
.OrderBy(a => a.SortOrder)
.OrderByDescending(a => a.IsRecommended)
.ThenBy(a => a.SortOrder)
.ToListAsync(cancellationToken);
var grouped = apps.GroupBy(a => a.OperatingSystem)
+10 -2
View File
@@ -13,6 +13,10 @@ public sealed class ClientApp : Entity
public int SortOrder { get; private set; }
public bool IsEnabled { get; private set; }
/// <summary>Рекомендованные приложения показываются первыми внутри своей группы ОС и
/// помечаются значком на странице инструкций.</summary>
public bool IsRecommended { get; private set; }
private ClientApp() { }
public static ClientApp Create(
@@ -21,7 +25,8 @@ public sealed class ClientApp : Entity
OsPlatform operatingSystem,
string? description,
string? iconUrl,
int sortOrder
int sortOrder,
bool isRecommended
)
{
return new ClientApp
@@ -34,6 +39,7 @@ public sealed class ClientApp : Entity
IconUrl = iconUrl,
SortOrder = sortOrder,
IsEnabled = true,
IsRecommended = isRecommended,
};
}
@@ -44,7 +50,8 @@ public sealed class ClientApp : Entity
string? description,
string? iconUrl,
int sortOrder,
bool isEnabled
bool isEnabled,
bool isRecommended
)
{
Name = name;
@@ -54,5 +61,6 @@ public sealed class ClientApp : Entity
IconUrl = iconUrl;
SortOrder = sortOrder;
IsEnabled = isEnabled;
IsRecommended = isRecommended;
}
}
@@ -49,7 +49,8 @@ internal sealed class ClientAppCatalogSeeder(
os,
entry.Description,
iconUrl: null,
entry.SortOrder
entry.SortOrder,
entry.IsRecommended
);
dbContext.ClientApps.Add(app);
}
@@ -64,6 +65,7 @@ internal sealed class ClientAppCatalogSeeder(
string DownloadUrl,
string? Description,
int SortOrder,
bool IsEnabled
bool IsEnabled,
bool IsRecommended = false
);
}
@@ -0,0 +1,890 @@
// <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("20260714160950_AddIsRecommendedToClientApp")]
partial class AddIsRecommendedToClientApp
{
/// <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<bool>("IsRecommended")
.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.News.NewsPost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("NewsPosts", (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.Support.SupportTicket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ProposedMaxConfigs")
.HasColumnType("integer");
b.Property<int?>("ProposedMaxIpLimit")
.HasColumnType("integer");
b.Property<string>("ProposedRoleName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("RequestedRoleId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Type", "Status");
b.HasIndex("UserId", "Status");
b.ToTable("SupportTickets", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CommentId")
.HasColumnType("uuid");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("StoredFileName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("CommentId");
b.HasIndex("StoredFileName")
.IsUnique();
b.ToTable("TicketAttachments", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TicketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TicketId", "CreatedAt");
b.ToTable("TicketComments", (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<int>("MaxIpLimit")
.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,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddIsRecommendedToClientApp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsRecommended",
table: "ClientApps",
type: "boolean",
nullable: false,
defaultValue: false
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "IsRecommended", table: "ClientApps");
}
}
}
@@ -185,6 +185,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsRecommended")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@@ -18,7 +18,8 @@ public class DeleteDisabledAppsCommandHandlerTests
OsPlatform.Android,
null,
null,
0
0,
isRecommended: false
);
disabledApp.Update(
disabledApp.Name,
@@ -27,7 +28,8 @@ public class DeleteDisabledAppsCommandHandlerTests
null,
null,
0,
isEnabled: false
isEnabled: false,
isRecommended: false
);
var enabledApp = ClientApp.Create(
@@ -36,7 +38,8 @@ public class DeleteDisabledAppsCommandHandlerTests
OsPlatform.IOS,
null,
null,
0
0,
isRecommended: false
);
dbContext.ClientApps.AddRange(disabledApp, enabledApp);
@@ -65,7 +68,8 @@ public class DeleteDisabledAppsCommandHandlerTests
OsPlatform.IOS,
null,
null,
0
0,
isRecommended: false
);
dbContext.ClientApps.Add(enabledApp);
await dbContext.SaveChangesAsync(CancellationToken.None);
@@ -0,0 +1,100 @@
using PnvPanel.Application.Apps;
using PnvPanel.Application.Tests.TestSupport;
using PnvPanel.Domain.Apps;
using Xunit;
namespace PnvPanel.Application.Tests.Apps;
public class ListAppsQueryHandlerTests
{
[Fact]
public async Task Handle_SortsRecommendedFirstWithinEachOsGroup()
{
using var dbContext = InMemoryDbContextFactory.Create();
var regular1 = ClientApp.Create(
"Regular 1",
new Uri("https://a.example.com"),
OsPlatform.Android,
null,
null,
10,
isRecommended: false
);
var recommended = ClientApp.Create(
"Recommended",
new Uri("https://b.example.com"),
OsPlatform.Android,
null,
null,
20,
isRecommended: true
);
var regular2 = ClientApp.Create(
"Regular 2",
new Uri("https://c.example.com"),
OsPlatform.Android,
null,
null,
30,
isRecommended: false
);
dbContext.ClientApps.AddRange(regular1, recommended, regular2);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new ListAppsQueryHandler(dbContext);
var result = await handler.Handle(new ListAppsQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
var androidApps = result.Value[OsPlatform.Android];
Assert.Equal(["Recommended", "Regular 1", "Regular 2"], androidApps.Select(a => a.Name));
}
[Fact]
public async Task Handle_ExcludesDisabledApps()
{
using var dbContext = InMemoryDbContextFactory.Create();
var enabled = ClientApp.Create(
"Enabled",
new Uri("https://a.example.com"),
OsPlatform.IOS,
null,
null,
10,
isRecommended: false
);
var disabled = ClientApp.Create(
"Disabled",
new Uri("https://b.example.com"),
OsPlatform.IOS,
null,
null,
20,
isRecommended: false
);
disabled.Update(
disabled.Name,
disabled.DownloadUrl,
disabled.OperatingSystem,
null,
null,
disabled.SortOrder,
isEnabled: false,
isRecommended: false
);
dbContext.ClientApps.AddRange(enabled, disabled);
await dbContext.SaveChangesAsync(CancellationToken.None);
var handler = new ListAppsQueryHandler(dbContext);
var result = await handler.Handle(new ListAppsQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
var iosApps = result.Value[OsPlatform.IOS];
Assert.Equal(["Enabled"], iosApps.Select(a => a.Name));
}
}
@@ -14,13 +14,15 @@ public class ClientAppTests
OsPlatform.Android,
"desc",
null,
1
1,
isRecommended: false
);
Assert.Equal("v2rayNG", app.Name);
Assert.Equal(OsPlatform.Android, app.OperatingSystem);
Assert.True(app.IsEnabled);
Assert.Equal(1, app.SortOrder);
Assert.False(app.IsRecommended);
}
[Fact]
@@ -32,7 +34,8 @@ public class ClientAppTests
OsPlatform.IOS,
"old",
"old-icon",
1
1,
isRecommended: false
);
app.Update(
@@ -42,7 +45,8 @@ public class ClientAppTests
"new",
"new-icon",
2,
isEnabled: false
isEnabled: false,
isRecommended: true
);
Assert.Equal("New", app.Name);
@@ -52,5 +56,6 @@ public class ClientAppTests
Assert.Equal("new-icon", app.IconUrl);
Assert.Equal(2, app.SortOrder);
Assert.False(app.IsEnabled);
Assert.True(app.IsRecommended);
}
}
+6 -2
View File
@@ -88,10 +88,14 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- |
| GET | `/api/apps` | user | — | `Record<OsPlatform, ClientAppDto[]>` |
| GET | `/api/admin/apps` | admin | — | `AdminAppDto[]` (вкл. выключенные) |
| POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder }` | `AdminAppDto` |
| PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled }` | `AdminAppDto` |
| POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isRecommended }` | `AdminAppDto` |
| PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled, isRecommended }` | `AdminAppDto` |
| DELETE | `/api/admin/apps/{id}` | admin | — | `204 No Content` |
`IsRecommended` — рекомендованные приложения идут первыми внутри своей группы ОС на `/api/apps`
(сортировка `IsRecommended desc, SortOrder asc`, применяется после фильтра `IsEnabled`), помечаются
значком-звездой на странице инструкций и в списке приложений в админке.
`GET /api/apps` → пример (только `isEnabled == true`, ОС без приложений в ответе отсутствует):
```json
{
+3 -1
View File
@@ -151,8 +151,10 @@ AppUser
| `IconUrl` | `string?` | Иконка (опц.) |
| `SortOrder` | `int` | Порядок внутри группы ОС |
| `IsEnabled` | `bool` | Показывать пользователям |
| `IsRecommended` | `bool` | Показывать первыми в группе ОС + значок на фронте |
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`.
Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`/`IsRecommended`, сгруппировано
по `OperatingSystem`; внутри группы `IsRecommended` (сначала true) → `SortOrder`.
Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста.
Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание»,
`DELETE /api/admin/maintenance/apps/disabled`.
@@ -24,6 +24,7 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto;
const [iconUrl, setIconUrl] = useState(app?.iconUrl ?? '')
const [sortOrder, setSortOrder] = useState(String(app?.sortOrder ?? 0))
const [isEnabled, setIsEnabled] = useState(app?.isEnabled ?? true)
const [isRecommended, setIsRecommended] = useState(app?.isRecommended ?? false)
const isControlled = open !== undefined
const dialogOpen = isControlled ? open : internalOpen
@@ -32,8 +33,26 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto;
const mutation = useMutation({
mutationFn: () =>
app
? updateApp(app.id, name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder), isEnabled)
: createApp(name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder)),
? updateApp(
app.id,
name.trim(),
downloadUrl.trim(),
operatingSystem,
description.trim() || undefined,
iconUrl.trim() || undefined,
Number(sortOrder),
isEnabled,
isRecommended,
)
: createApp(
name.trim(),
downloadUrl.trim(),
operatingSystem,
description.trim() || undefined,
iconUrl.trim() || undefined,
Number(sortOrder),
isRecommended,
),
onSuccess: async () => {
toast.success(app ? t('admin.apps.updated') : t('admin.apps.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
@@ -97,6 +116,10 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto;
<Label htmlFor="sortOrder">{t('admin.apps.sortOrder')}</Label>
<Input id="sortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isRecommended} onChange={(e) => setIsRecommended(e.target.checked)} />
{t('admin.apps.recommended')}
</label>
{app && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
+21 -2
View File
@@ -12,10 +12,19 @@ export function createApp(
description: string | undefined,
iconUrl: string | undefined,
sortOrder: number,
isRecommended: boolean,
) {
return apiRequest<AdminAppDto>('/admin/apps', {
method: 'POST',
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder },
body: {
name,
downloadUrl,
operatingSystem,
description: description ?? null,
iconUrl: iconUrl ?? null,
sortOrder,
isRecommended,
},
})
}
@@ -28,10 +37,20 @@ export function updateApp(
iconUrl: string | undefined,
sortOrder: number,
isEnabled: boolean,
isRecommended: boolean,
) {
return apiRequest<AdminAppDto>(`/admin/apps/${id}`, {
method: 'PUT',
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder, isEnabled },
body: {
name,
downloadUrl,
operatingSystem,
description: description ?? null,
iconUrl: iconUrl ?? null,
sortOrder,
isEnabled,
isRecommended,
},
})
}
+10 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Download } from 'lucide-react'
import { Download, Star } from 'lucide-react'
import { Card, CardHeader, CardTitle } from '@/shared/ui/card'
import { cn } from '@/shared/lib/cn'
import type { OsPlatform } from '@/shared/api/types'
@@ -58,7 +58,15 @@ export function AppsCatalog() {
<Download className="h-6 w-6 text-muted-foreground" />
)}
<div>
<CardTitle className="text-sm">{app.name}</CardTitle>
<CardTitle className="flex items-center gap-1.5 text-sm">
{app.name}
{app.isRecommended && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-yellow-400 text-yellow-400"
aria-label={t('instructions.recommended')}
/>
)}
</CardTitle>
{app.description && <p className="text-xs text-muted-foreground">{app.description}</p>}
</div>
</CardHeader>
+4
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Star } from 'lucide-react'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
@@ -58,6 +59,9 @@ function AdminAppsPage() {
.map((app) => (
<div key={app.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
<div className="flex items-center gap-2">
{app.isRecommended && (
<Star className="h-3.5 w-3.5 shrink-0 fill-yellow-400 text-yellow-400" aria-label={t('admin.apps.recommended')} />
)}
<span>{app.name}</span>
{!app.isEnabled && <Badge variant="outline">{t('admin.apps.disabled')}</Badge>}
</div>
+2
View File
@@ -102,6 +102,7 @@ export type ClientAppDto = {
downloadUrl: string
description: string | null
iconUrl: string | null
isRecommended: boolean
}
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
@@ -212,6 +213,7 @@ export type AdminAppDto = {
iconUrl: string | null
sortOrder: number
isEnabled: boolean
isRecommended: boolean
}
export type StatsDto = {
+4
View File
@@ -108,6 +108,7 @@ const resources = {
step3: 'Импортируйте ссылку или отсканируйте QR в приложении — готово.',
appsTitle: 'Приложения',
noApps: 'Каталог приложений пока пуст.',
recommended: 'Рекомендуем',
os: {
IOS: 'iOS',
Android: 'Android',
@@ -311,6 +312,7 @@ const resources = {
sortOrder: 'Порядок',
enabled: 'Включено',
disabled: 'отключено',
recommended: 'Рекомендовано',
empty: 'Каталог приложений пуст.',
created: 'Приложение добавлено.',
updated: 'Приложение обновлено.',
@@ -513,6 +515,7 @@ const resources = {
step3: 'Import the link or scan the QR code in the app — done.',
appsTitle: 'Apps',
noApps: 'The app catalog is empty right now.',
recommended: 'Recommended',
os: {
IOS: 'iOS',
Android: 'Android',
@@ -716,6 +719,7 @@ const resources = {
sortOrder: 'Sort order',
enabled: 'Enabled',
disabled: 'disabled',
recommended: 'Recommended',
empty: 'The app catalog is empty.',
created: 'App added.',
updated: 'App updated.',