Add factory reset functionality and update identity service
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Introduced a new DELETE endpoint `/api/admin/maintenance/factory-reset` for a complete reset of the admin panel, removing all users except the current admin and clearing various data.
- Implemented the `FactoryReset` method in `AdminMaintenanceEndpoints` to handle the reset logic.
- Added a new method `ListAllUserIdsExceptAsync` in `IIdentityService` to retrieve user IDs excluding a specified user, aiding in the factory reset process.
- Updated the frontend to include a confirmation dialog for the factory reset action, enhancing user experience and safety.
- Enhanced localization support for the new factory reset feature in both Russian and English, ensuring clarity for all users.
This commit is contained in:
Leonid Pershin
2026-07-14 18:58:51 +03:00
parent 94ba514b8e
commit 701c3a1d51
16 changed files with 564 additions and 64 deletions
@@ -23,6 +23,7 @@ public static class AdminMaintenanceEndpoints
admin
.MapDelete("/apps/disabled", DeleteDisabledApps)
.Produces<MaintenanceCleanupResponseDto>();
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
return app;
}
@@ -58,6 +59,15 @@ public static class AdminMaintenanceEndpoints
return ToResponse(result);
}
private static async Task<IResult> FactoryReset(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FactoryResetCommand(), cancellationToken);
return result.ToHttpResult();
}
private static IResult ToResponse(Result<int> result) =>
result.IsSuccess
? Results.Ok(new MaintenanceCleanupResponseDto(result.Value))
@@ -0,0 +1,11 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Maintenance;
/// <summary>
/// Полный сброс панели к состоянию свежего деплоя: все пользователи кроме текущего админа,
/// конфиги, ноды/инбаунды, тикеты (+вложения), новости, аудит и кастомные роли удаляются;
/// каталог приложений пересеивается из seed/client-apps.json. Необратимо.
/// </summary>
public sealed record FactoryResetCommand : ICommand<Result>;
@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Admin.Maintenance;
public sealed class FactoryResetCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IRoleService roleService,
IXuiPanelGateway gateway,
IFileStorage fileStorage,
IClientAppCatalogSeeder catalogSeeder,
ICurrentUser currentUser
) : ICommandHandler<FactoryResetCommand, Result>
{
public async Task<Result> Handle(
FactoryResetCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
await RevokeAllConfigsOnPanelsAsync(cancellationToken);
await DeleteTicketAttachmentFilesAsync(cancellationToken);
WipeApplicationData();
await dbContext.SaveChangesAsync(cancellationToken);
// Пользователи/роли удаляются через Identity, а не через dbContext — коммитим сначала
// "плоские" данные выше, чтобы к этому моменту на них уже никто не ссылался.
var otherUserIds = await identityService.ListAllUserIdsExceptAsync(
adminId,
cancellationToken
);
foreach (var userId in otherUserIds)
await identityService.DeleteUserAsync(userId, cancellationToken);
var roles = await roleService.ListRolesAsync(cancellationToken);
foreach (var role in roles.Where(r => !r.IsSystem))
await roleService.DeleteRoleAsync(role.Id, cancellationToken);
// ClientApps уже пуста (удалена в WipeApplicationData + сохранена выше) — пересеиваем.
await catalogSeeder.SeedIfEmptyAsync(cancellationToken);
// Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса.
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"FactoryReset",
"System",
"all",
metadata: null,
AuditSource.Web
)
);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
/// <summary>Best-effort — недоступная/уже удаляемая нода не должна блокировать сброс остального.</summary>
private async Task RevokeAllConfigsOnPanelsAsync(CancellationToken cancellationToken)
{
var configs = await dbContext
.VpnConfigs.AsNoTracking()
.Where(c => c.Status != ConfigStatus.Revoked)
.ToListAsync(cancellationToken);
if (configs.Count == 0)
return;
var inbounds = await dbContext.Inbounds.AsNoTracking().ToListAsync(cancellationToken);
var nodes = await dbContext.Nodes.AsNoTracking().ToListAsync(cancellationToken);
foreach (var config in configs)
{
var inbound = inbounds.FirstOrDefault(i => i.Id == config.InboundId);
var node = inbound is null ? null : nodes.FirstOrDefault(n => n.Id == inbound.NodeId);
if (inbound is null || node is null)
continue;
await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
cancellationToken
);
}
}
private async Task DeleteTicketAttachmentFilesAsync(CancellationToken cancellationToken)
{
var attachments = await dbContext
.TicketAttachments.AsNoTracking()
.ToListAsync(cancellationToken);
foreach (var attachment in attachments)
await fileStorage.DeleteAsync(attachment.StoredFileName, cancellationToken);
}
private void WipeApplicationData()
{
dbContext.TicketAttachments.RemoveRange(dbContext.TicketAttachments);
dbContext.TicketComments.RemoveRange(dbContext.TicketComments);
dbContext.SupportTickets.RemoveRange(dbContext.SupportTickets);
dbContext.TrafficSamples.RemoveRange(dbContext.TrafficSamples);
dbContext.VpnConfigs.RemoveRange(dbContext.VpnConfigs);
dbContext.Inbounds.RemoveRange(dbContext.Inbounds);
dbContext.Nodes.RemoveRange(dbContext.Nodes);
dbContext.ActivationRequests.RemoveRange(dbContext.ActivationRequests);
dbContext.TelegramLoginRequests.RemoveRange(dbContext.TelegramLoginRequests);
dbContext.TelegramLinkTokens.RemoveRange(dbContext.TelegramLinkTokens);
dbContext.NewsPosts.RemoveRange(dbContext.NewsPosts);
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
}
}
@@ -0,0 +1,11 @@
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>
/// Сидинг стартового каталога клиентских приложений из seed/client-apps.json. Идемпотентно —
/// не трогает таблицу, если в ней уже что-то есть (используется и при старте, и после полного
/// сброса панели, когда таблица заведомо пуста).
/// </summary>
public interface IClientAppCatalogSeeder
{
Task SeedIfEmptyAsync(CancellationToken cancellationToken);
}
@@ -128,4 +128,11 @@ public interface IIdentityService
Task<IReadOnlyCollection<long>> GetActivatedLinkedTelegramUserIdsAsync(
CancellationToken cancellationToken
);
/// <summary>Все Id пользователей, кроме указанного — для полного сброса панели (сохраняется
/// только текущий админ).</summary>
Task<IReadOnlyList<Guid>> ListAllUserIdsExceptAsync(
Guid exceptUserId,
CancellationToken cancellationToken
);
}
@@ -0,0 +1,69 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Apps;
using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.Apps;
internal sealed class ClientAppCatalogSeeder(
AppDbContext dbContext,
ILogger<ClientAppCatalogSeeder> logger
) : IClientAppCatalogSeeder
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task SeedIfEmptyAsync(CancellationToken cancellationToken)
{
if (await dbContext.ClientApps.AnyAsync(cancellationToken))
return;
var path = Path.Combine(AppContext.BaseDirectory, "seed", "client-apps.json");
if (!File.Exists(path))
{
logger.LogWarning("Client app catalog seed file not found: {Path}", path);
return;
}
var json = await File.ReadAllTextAsync(path, cancellationToken);
var entries = JsonSerializer.Deserialize<List<ClientAppSeedEntry>>(json, JsonOptions) ?? [];
foreach (var entry in entries)
{
if (!Enum.TryParse<OsPlatform>(entry.OperatingSystem, ignoreCase: true, out var os))
{
logger.LogWarning(
"Unknown OS '{Os}' in client app catalog seed — skipped",
entry.OperatingSystem
);
continue;
}
var app = ClientApp.Create(
entry.Name,
new Uri(entry.DownloadUrl, UriKind.Absolute),
os,
entry.Description,
iconUrl: null,
entry.SortOrder
);
dbContext.ClientApps.Add(app);
}
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("Seeded client app catalog: {Count} entries", entries.Count);
}
private sealed record ClientAppSeedEntry(
string Name,
string OperatingSystem,
string DownloadUrl,
string? Description,
int SortOrder,
bool IsEnabled
);
}
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Infrastructure.Apps;
using PnvPanel.Infrastructure.BackgroundJobs;
using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Persistence;
@@ -135,6 +136,7 @@ public static class DependencyInjection
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>();
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
services.AddScoped<CurrentUser>();
@@ -1,11 +1,8 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PnvPanel.Domain.Apps;
using PnvPanel.Infrastructure.Persistence;
using PnvPanel.Application.Common.Interfaces;
namespace PnvPanel.Infrastructure.Identity;
@@ -13,17 +10,12 @@ namespace PnvPanel.Infrastructure.Identity;
public sealed class DbInitializer(
RoleManager<AppRole> roleManager,
UserManager<AppUser> userManager,
AppDbContext dbContext,
IClientAppCatalogSeeder clientAppCatalogSeeder,
IOptions<AdminSeedOptions> adminSeedOptions,
IOptions<RolesOptions> rolesOptions,
ILogger<DbInitializer> logger
)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
await EnsureRoleAsync(
@@ -39,7 +31,7 @@ public sealed class DbInitializer(
isSystem: true
);
await SeedAdminAsync();
await SeedClientAppsAsync(cancellationToken);
await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken);
}
private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem)
@@ -100,54 +92,4 @@ public sealed class DbInitializer(
await userManager.AddToRoleAsync(admin, RoleNames.Admin);
logger.LogInformation("Created admin account {Username}", options.Username);
}
private async Task SeedClientAppsAsync(CancellationToken cancellationToken)
{
if (await dbContext.ClientApps.AnyAsync(cancellationToken))
return;
var path = Path.Combine(AppContext.BaseDirectory, "seed", "client-apps.json");
if (!File.Exists(path))
{
logger.LogWarning("Client app catalog seed file not found: {Path}", path);
return;
}
var json = await File.ReadAllTextAsync(path, cancellationToken);
var entries = JsonSerializer.Deserialize<List<ClientAppSeedEntry>>(json, JsonOptions) ?? [];
foreach (var entry in entries)
{
if (!Enum.TryParse<OsPlatform>(entry.OperatingSystem, ignoreCase: true, out var os))
{
logger.LogWarning(
"Unknown OS '{Os}' in client app catalog seed — skipped",
entry.OperatingSystem
);
continue;
}
var app = ClientApp.Create(
entry.Name,
new Uri(entry.DownloadUrl, UriKind.Absolute),
os,
entry.Description,
iconUrl: null,
entry.SortOrder
);
dbContext.ClientApps.Add(app);
}
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("Seeded client app catalog: {Count} entries", entries.Count);
}
private sealed record ClientAppSeedEntry(
string Name,
string OperatingSystem,
string DownloadUrl,
string? Description,
int SortOrder,
bool IsEnabled
);
}
@@ -364,6 +364,18 @@ internal sealed class IdentityService(
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Guid>> ListAllUserIdsExceptAsync(
Guid exceptUserId,
CancellationToken cancellationToken
)
{
return await userManager
.Users.AsNoTracking()
.Where(u => u.Id != exceptUserId)
.Select(u => u.Id)
.ToListAsync(cancellationToken);
}
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);