From 701c3a1d51d3f283f8964a5f3fd7e63804664c7e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 14 Jul 2026 18:58:51 +0300 Subject: [PATCH] Add factory reset functionality and update identity service - 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. --- .../Endpoints/AdminMaintenanceEndpoints.cs | 10 ++ .../Admin/Maintenance/FactoryResetCommand.cs | 11 ++ .../Maintenance/FactoryResetCommandHandler.cs | 121 +++++++++++++++ .../Interfaces/IClientAppCatalogSeeder.cs | 11 ++ .../Common/Interfaces/IIdentityService.cs | 7 + .../Apps/ClientAppCatalogSeeder.cs | 69 +++++++++ .../DependencyInjection.cs | 2 + .../Identity/DbInitializer.cs | 64 +------- .../Identity/IdentityService.cs | 12 ++ .../FactoryResetCommandHandlerTests.cs | 144 ++++++++++++++++++ docs/api-design.md | 13 ++ docs/domain-model.md | 14 +- .../admin/maintenance/FactoryResetDialog.tsx | 80 ++++++++++ .../src/features/admin/maintenance/api.ts | 4 + frontend/src/routes/admin/maintenance.tsx | 20 +++ frontend/src/shared/lib/i18n.ts | 46 ++++++ 16 files changed, 564 insertions(+), 64 deletions(-) create mode 100644 backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IClientAppCatalogSeeder.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs create mode 100644 frontend/src/features/admin/maintenance/FactoryResetDialog.tsx diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs index 5638c90..c9e09aa 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminMaintenanceEndpoints.cs @@ -23,6 +23,7 @@ public static class AdminMaintenanceEndpoints admin .MapDelete("/apps/disabled", DeleteDisabledApps) .Produces(); + 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 FactoryReset( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new FactoryResetCommand(), cancellationToken); + return result.ToHttpResult(); + } + private static IResult ToResponse(Result result) => result.IsSuccess ? Results.Ok(new MaintenanceCleanupResponseDto(result.Value)) diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommand.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommand.cs new file mode 100644 index 0000000..4e8a819 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommand.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Maintenance; + +/// +/// Полный сброс панели к состоянию свежего деплоя: все пользователи кроме текущего админа, +/// конфиги, ноды/инбаунды, тикеты (+вложения), новости, аудит и кастомные роли удаляются; +/// каталог приложений пересеивается из seed/client-apps.json. Необратимо. +/// +public sealed record FactoryResetCommand : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs new file mode 100644 index 0000000..025f5d1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs @@ -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 +{ + public async Task 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(); + } + + /// Best-effort — недоступная/уже удаляемая нода не должна блокировать сброс остального. + 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); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IClientAppCatalogSeeder.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IClientAppCatalogSeeder.cs new file mode 100644 index 0000000..5264fa8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IClientAppCatalogSeeder.cs @@ -0,0 +1,11 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Сидинг стартового каталога клиентских приложений из seed/client-apps.json. Идемпотентно — +/// не трогает таблицу, если в ней уже что-то есть (используется и при старте, и после полного +/// сброса панели, когда таблица заведомо пуста). +/// +public interface IClientAppCatalogSeeder +{ + Task SeedIfEmptyAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index 1069978..85f1795 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -128,4 +128,11 @@ public interface IIdentityService Task> GetActivatedLinkedTelegramUserIdsAsync( CancellationToken cancellationToken ); + + /// Все Id пользователей, кроме указанного — для полного сброса панели (сохраняется + /// только текущий админ). + Task> ListAllUserIdsExceptAsync( + Guid exceptUserId, + CancellationToken cancellationToken + ); } diff --git a/backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs b/backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs new file mode 100644 index 0000000..046a841 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs @@ -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 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>(json, JsonOptions) ?? []; + + foreach (var entry in entries) + { + if (!Enum.TryParse(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 + ); +} diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs index 00a41f2..9c12b8f 100644 --- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как // ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService). services.AddScoped(); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs index c0548d4..14975a8 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs @@ -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 roleManager, UserManager userManager, - AppDbContext dbContext, + IClientAppCatalogSeeder clientAppCatalogSeeder, IOptions adminSeedOptions, IOptions rolesOptions, ILogger 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>(json, JsonOptions) ?? []; - - foreach (var entry in entries) - { - if (!Enum.TryParse(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 - ); } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index 3315c5f..e106223 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -364,6 +364,18 @@ internal sealed class IdentityService( .ToListAsync(cancellationToken); } + public async Task> ListAllUserIdsExceptAsync( + Guid exceptUserId, + CancellationToken cancellationToken + ) + { + return await userManager + .Users.AsNoTracking() + .Where(u => u.Id != exceptUserId) + .Select(u => u.Id) + .ToListAsync(cancellationToken); + } + private async Task GetPrimaryRoleNameAsync(AppUser user) { var roles = await userManager.GetRolesAsync(user); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs new file mode 100644 index 0000000..b81609f --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs @@ -0,0 +1,144 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Maintenance; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.News; +using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Support; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Maintenance; + +public class FactoryResetCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IRoleService _roleService = Substitute.For(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IFileStorage _fileStorage = Substitute.For(); + private readonly IClientAppCatalogSeeder _catalogSeeder = + Substitute.For(); + + [Fact] + public async Task Handle_WipesEverythingExceptCurrentAdminAndSystemRoles() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + + var node = Node.Register( + "node-1", + new Uri("https://node.example.com"), + new NodeCredentials("u", "p"), + null + ); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var config = VpnConfig.Create(otherUserId, inbound.Id, VpnProtocol.Vless, "label"); + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + + var ticket = SupportTicket.CreateBugReport(otherUserId); + dbContext.SupportTickets.Add(ticket); + var comment = TicketComment.Create(ticket.Id, otherUserId, "текст"); + dbContext.TicketComments.Add(comment); + var attachment = TicketAttachment.Create( + comment.Id, + "shot.png", + "stored-name", + "image/png", + 10 + ); + dbContext.TicketAttachments.Add(attachment); + + dbContext.NewsPosts.Add(NewsPost.Create("Заголовок", "Текст")); + dbContext.AuditLogs.Add( + AuditLog.Create(adminId, "SomeOldAction", "Test", "1", null, AuditSource.Web) + ); + + await dbContext.SaveChangesAsync(CancellationToken.None); + + var adminRoleId = Guid.NewGuid(); + var customRoleId = Guid.NewGuid(); + _roleService + .ListRolesAsync(Arg.Any()) + .Returns( + new List + { + new(adminRoleId, "admin", -1, -1, IsSystem: true), + new(customRoleId, "premium", 10, 5, IsSystem: false), + } + ); + _roleService + .DeleteRoleAsync(customRoleId, Arg.Any()) + .Returns(Result.Success()); + + _identityService + .ListAllUserIdsExceptAsync(adminId, Arg.Any()) + .Returns(new List { otherUserId }); + _identityService + .DeleteUserAsync(otherUserId, Arg.Any()) + .Returns(Result.Success()); + + _gateway + .RemoveClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(Result.Success()); + + var currentUser = FakeCurrentUser.Authenticated(adminId, "admin"); + var handler = new FactoryResetCommandHandler( + dbContext, + _identityService, + _roleService, + _gateway, + _fileStorage, + _catalogSeeder, + currentUser + ); + + var result = await handler.Handle(new FactoryResetCommand(), CancellationToken.None); + // Хендлер сам коммитит несколько раз (в проде это делает он же, а не диспетчер) — фиксируем финально. + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + + Assert.False(dbContext.VpnConfigs.Any()); + Assert.False(dbContext.Inbounds.Any()); + Assert.False(dbContext.Nodes.Any()); + Assert.False(dbContext.SupportTickets.Any()); + Assert.False(dbContext.TicketComments.Any()); + Assert.False(dbContext.TicketAttachments.Any()); + Assert.False(dbContext.NewsPosts.Any()); + + // Аудит очищен, но остаётся ровно одна собственная запись о факте сброса. + var auditLog = Assert.Single(dbContext.AuditLogs); + Assert.Equal("FactoryReset", auditLog.Action); + + await _identityService + .Received(1) + .DeleteUserAsync(otherUserId, Arg.Any()); + await _roleService.Received(1).DeleteRoleAsync(customRoleId, Arg.Any()); + await _roleService + .DidNotReceive() + .DeleteRoleAsync(adminRoleId, Arg.Any()); + await _gateway + .Received(1) + .RemoveClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + config.ClientExternalId, + config.Protocol, + Arg.Any() + ); + await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any()); + await _catalogSeeder.Received(1).SeedIfEmptyAsync(Arg.Any()); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index a614913..83b3a23 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -183,6 +183,7 @@ Support.CannotRequestAdminRole`), либо все три поля новой р | DELETE | `/api/admin/maintenance/tickets/closed` | — | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) | | DELETE | `/api/admin/maintenance/audit-logs` | query: `olderThanDays` (1–3650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней | | DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` | +| DELETE | `/api/admin/maintenance/factory-reset` | — | `204 No Content` — полный сброс панели к состоянию свежего деплоя | Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер удаляет вручную в порядке вложения → комментарии → тикеты. @@ -191,6 +192,18 @@ Support.CannotRequestAdminRole`), либо все три поля новой р её `CreatedAt` позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет осознанно — `AuditLog` в проекте append-only, доступна только очистка по возрасту. +**`factory-reset`** — самая деструктивная операция панели, на фронте спрятана под спойлер +(«Опасная зона») и требует ввести фразу-подтверждение в диалоге (не просто `confirm()`). Удаляет: +всех пользователей кроме текущего админа, все `VpnConfig`/`TrafficSample` (конфиги сначала best-effort +отзываются на нодах через `IXuiPanelGateway.RemoveClientAsync` — недоступная нода не блокирует сброс), +все `Node`/`Inbound`, тикеты с перепиской/вложениями (+файлы), `NewsPost`, весь `AuditLog`, все +кастомные роли (`AppRole.IsSystem == false`) и `ClientApp` — каталог приложений затем пересеивается +через `IClientAppCatalogSeeder` (тот же сервис, что использует `DbInitializer` при первом старте). +Не атомарно целиком (несколько `SaveChangesAsync` внутри хендлера, как и в `DeleteUserCommandHandler`) — +при сбое посередине возможно частичное состояние, компенсации нет, это осознанный компромисс для +редкой ручной админской операции. Финальная запись `FactoryReset` в аудит добавляется уже после +очистки самого журнала. + ## Admin — Activation, Roles | Метод | Путь | Роль | Тело запроса | Тело ответа | diff --git a/docs/domain-model.md b/docs/domain-model.md index e6dd35f..ac85fb1 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -187,9 +187,17 @@ AppUser | `CreatedAt` | `DateTimeOffset` | | Пишется из хендлеров (или обработчиков доменных событий), append-only — из приложения ничего не -удаляет и не редактирует записи. Единственное исключение — retention-очистка по возрасту (вкладка -«Обслуживание», `DELETE /api/admin/maintenance/audit-logs?olderThanDays=N`), доступная только -админу; полного удаления журнала осознанно нет. +удаляет и не редактирует записи. Исключения — retention-очистка по возрасту (вкладка «Обслуживание», +`DELETE /api/admin/maintenance/audit-logs?olderThanDays=N`) и полный сброс панели (см. ниже), оба +доступны только админу; отдельной кнопки «удалить весь журнал» без сброса всей панели осознанно нет. + +### Полный сброс панели +Вкладка «Обслуживание» → «Опасная зона» (спойлер + подтверждение фразой в диалоге, не просто +`confirm()`) — `DELETE /api/admin/maintenance/factory-reset`. Возвращает панель к состоянию свежего +деплоя: удаляет всех пользователей кроме текущего админа, конфиги (сначала best-effort отзываются +на нодах 3x-ui), ноды/инбаунды, тикеты, новости, весь аудит и кастомные роли; каталог приложений +пересеивается из `seed/client-apps.json`. Необратимо, не атомарно целиком — подробности и полный +список удаляемого см. [api-design.md](api-design.md#admin--maintenance). ### AppUser — расширения (Identity) `AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный). diff --git a/frontend/src/features/admin/maintenance/FactoryResetDialog.tsx b/frontend/src/features/admin/maintenance/FactoryResetDialog.tsx new file mode 100644 index 0000000..dea5c18 --- /dev/null +++ b/frontend/src/features/admin/maintenance/FactoryResetDialog.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from '@/shared/ui/toast-store' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' +import { factoryReset } from './api' + +const CONFIRM_PHRASE_KEY = 'admin.maintenance.factoryReset.confirmPhrase' + +export function FactoryResetDialog() { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [confirmText, setConfirmText] = useState('') + const confirmPhrase = t(CONFIRM_PHRASE_KEY) + + const mutation = useMutation({ + mutationFn: factoryReset, + onSuccess: () => { + toast.success(t('admin.maintenance.factoryReset.done')) + // Почти всё состояние приложения (пользователи/роли/ноды/кэши списков) больше не валидно — + // проще перезагрузить всю панель, чем точечно инвалидировать десяток query-ключей. + window.location.href = '/admin' + }, + onError: () => toast.error(t('auth.genericError')), + }) + + return ( + { + setOpen(next) + if (!next) setConfirmText('') + }} + > + + + + + + {t('admin.maintenance.factoryReset.dialogTitle')} + +
+

{t('admin.maintenance.factoryReset.warningIntro')}

+
    + {(t('admin.maintenance.factoryReset.warningItems', { returnObjects: true }) as string[]).map( + (item) => ( +
  • {item}
  • + ), + )} +
+

{t('admin.maintenance.factoryReset.irreversible')}

+
+
+
+ +
+ + setConfirmText(e.target.value)} autoComplete="off" /> +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/maintenance/api.ts b/frontend/src/features/admin/maintenance/api.ts index 3f05fcc..c6622f2 100644 --- a/frontend/src/features/admin/maintenance/api.ts +++ b/frontend/src/features/admin/maintenance/api.ts @@ -14,3 +14,7 @@ export function deleteOldAuditLogs(olderThanDays: number) { export function deleteDisabledApps() { return apiRequest('/admin/maintenance/apps/disabled', { method: 'DELETE' }) } + +export function factoryReset() { + return apiRequest('/admin/maintenance/factory-reset', { method: 'DELETE' }) +} diff --git a/frontend/src/routes/admin/maintenance.tsx b/frontend/src/routes/admin/maintenance.tsx index 2824f8a..7dfd711 100644 --- a/frontend/src/routes/admin/maintenance.tsx +++ b/frontend/src/routes/admin/maintenance.tsx @@ -7,6 +7,7 @@ import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api' +import { FactoryResetDialog } from '@/features/admin/maintenance/FactoryResetDialog' export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage }) @@ -118,6 +119,25 @@ function AdminMaintenancePage() { + +
+ + {t('admin.maintenance.factoryReset.dangerZone')} + +
+ + + {t('admin.maintenance.factoryReset.title')} + + +

{t('admin.maintenance.factoryReset.description')}

+
+ +
+
+
+
+
) } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index c4f6dc3..4fe119d 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -370,6 +370,29 @@ const resources = { confirm: 'Удалить все отключённые приложения? Действие необратимо.', deleted: 'Удалено приложений: {{count}}.', }, + factoryReset: { + dangerZone: 'Опасная зона', + title: 'Полный сброс панели', + description: 'Вернуть панель в состояние только что развёрнутой — удалить всех пользователей, конфиги, ноды и все данные. Ваш текущий аккаунт сохранится.', + action: 'Сбросить панель', + dialogTitle: 'Полный сброс панели', + warningIntro: 'Будет удалено безвозвратно:', + warningItems: [ + 'Все пользователи, кроме вашего текущего аккаунта', + 'Все VPN-конфиги (сначала будут отозваны на нодах 3x-ui)', + 'Все ноды и инбаунды — регистрация серверов 3x-ui и сохранённые креды', + 'Все обращения в поддержку вместе с перепиской и вложениями', + 'Все новости', + 'Весь журнал аудита', + 'Все роли, кроме системных admin/user', + 'Каталог приложений (будет пересеян из стартового набора)', + ], + irreversible: 'Действие необратимо и не может быть отменено.', + confirmPhrase: 'СБРОС', + confirmInputLabel: 'Введите «{{phrase}}» для подтверждения', + confirmButton: 'Сбросить панель безвозвратно', + done: 'Панель сброшена до начального состояния.', + }, }, stats: { totalUsers: 'Всего пользователей', @@ -752,6 +775,29 @@ const resources = { confirm: 'Delete all disabled apps? This cannot be undone.', deleted: 'Deleted apps: {{count}}.', }, + factoryReset: { + dangerZone: 'Danger zone', + title: 'Full panel reset', + description: 'Return the panel to a freshly-deployed state — deletes all users, configs, nodes and data. Your current account is kept.', + action: 'Reset panel', + dialogTitle: 'Full panel reset', + warningIntro: 'This will permanently delete:', + warningItems: [ + 'All users except your current account', + 'All VPN configs (revoked on the 3x-ui nodes first)', + 'All nodes and inbounds — 3x-ui server registrations and stored credentials', + 'All support tickets with their comments and attachments', + 'All news posts', + 'The entire audit log', + 'All roles except the system admin/user roles', + 'The app catalog (will be re-seeded from the default set)', + ], + irreversible: 'This cannot be undone.', + confirmPhrase: 'RESET', + confirmInputLabel: 'Type "{{phrase}}" to confirm', + confirmButton: 'Reset the panel permanently', + done: 'The panel has been reset to its initial state.', + }, }, stats: { totalUsers: 'Total users',