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.
This commit is contained in:
@@ -23,6 +23,7 @@ public static class AdminMaintenanceEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
||||||
.Produces<MaintenanceCleanupResponseDto>();
|
.Produces<MaintenanceCleanupResponseDto>();
|
||||||
|
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,15 @@ public static class AdminMaintenanceEndpoints
|
|||||||
return ToResponse(result);
|
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) =>
|
private static IResult ToResponse(Result<int> result) =>
|
||||||
result.IsSuccess
|
result.IsSuccess
|
||||||
? Results.Ok(new MaintenanceCleanupResponseDto(result.Value))
|
? 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(
|
Task<IReadOnlyCollection<long>> GetActivatedLinkedTelegramUserIdsAsync(
|
||||||
CancellationToken cancellationToken
|
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.Extensions.DependencyInjection;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Infrastructure.Apps;
|
||||||
using PnvPanel.Infrastructure.BackgroundJobs;
|
using PnvPanel.Infrastructure.BackgroundJobs;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
using PnvPanel.Infrastructure.Persistence;
|
using PnvPanel.Infrastructure.Persistence;
|
||||||
@@ -135,6 +136,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||||
services.AddScoped<IRoleService, RoleService>();
|
services.AddScoped<IRoleService, RoleService>();
|
||||||
|
services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>();
|
||||||
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
|
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
|
||||||
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
|
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
|
||||||
services.AddScoped<CurrentUser>();
|
services.AddScoped<CurrentUser>();
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PnvPanel.Domain.Apps;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace PnvPanel.Infrastructure.Identity;
|
namespace PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
@@ -13,17 +10,12 @@ namespace PnvPanel.Infrastructure.Identity;
|
|||||||
public sealed class DbInitializer(
|
public sealed class DbInitializer(
|
||||||
RoleManager<AppRole> roleManager,
|
RoleManager<AppRole> roleManager,
|
||||||
UserManager<AppUser> userManager,
|
UserManager<AppUser> userManager,
|
||||||
AppDbContext dbContext,
|
IClientAppCatalogSeeder clientAppCatalogSeeder,
|
||||||
IOptions<AdminSeedOptions> adminSeedOptions,
|
IOptions<AdminSeedOptions> adminSeedOptions,
|
||||||
IOptions<RolesOptions> rolesOptions,
|
IOptions<RolesOptions> rolesOptions,
|
||||||
ILogger<DbInitializer> logger
|
ILogger<DbInitializer> logger
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await EnsureRoleAsync(
|
await EnsureRoleAsync(
|
||||||
@@ -39,7 +31,7 @@ public sealed class DbInitializer(
|
|||||||
isSystem: true
|
isSystem: true
|
||||||
);
|
);
|
||||||
await SeedAdminAsync();
|
await SeedAdminAsync();
|
||||||
await SeedClientAppsAsync(cancellationToken);
|
await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem)
|
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);
|
await userManager.AddToRoleAsync(admin, RoleNames.Admin);
|
||||||
logger.LogInformation("Created admin account {Username}", options.Username);
|
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);
|
.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)
|
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
|
||||||
{
|
{
|
||||||
var roles = await userManager.GetRolesAsync(user);
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
|||||||
+144
@@ -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<IIdentityService>();
|
||||||
|
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
|
||||||
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
|
private readonly IFileStorage _fileStorage = Substitute.For<IFileStorage>();
|
||||||
|
private readonly IClientAppCatalogSeeder _catalogSeeder =
|
||||||
|
Substitute.For<IClientAppCatalogSeeder>();
|
||||||
|
|
||||||
|
[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<CancellationToken>())
|
||||||
|
.Returns(
|
||||||
|
new List<RoleDto>
|
||||||
|
{
|
||||||
|
new(adminRoleId, "admin", -1, -1, IsSystem: true),
|
||||||
|
new(customRoleId, "premium", 10, 5, IsSystem: false),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
_roleService
|
||||||
|
.DeleteRoleAsync(customRoleId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Result.Success());
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.ListAllUserIdsExceptAsync(adminId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Guid> { otherUserId });
|
||||||
|
_identityService
|
||||||
|
.DeleteUserAsync(otherUserId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Result.Success());
|
||||||
|
|
||||||
|
_gateway
|
||||||
|
.RemoveClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<VpnProtocol>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
)
|
||||||
|
.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<CancellationToken>());
|
||||||
|
await _roleService.Received(1).DeleteRoleAsync(customRoleId, Arg.Any<CancellationToken>());
|
||||||
|
await _roleService
|
||||||
|
.DidNotReceive()
|
||||||
|
.DeleteRoleAsync(adminRoleId, Arg.Any<CancellationToken>());
|
||||||
|
await _gateway
|
||||||
|
.Received(1)
|
||||||
|
.RemoveClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
config.ClientExternalId,
|
||||||
|
config.Protocol,
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any<CancellationToken>());
|
||||||
|
await _catalogSeeder.Received(1).SeedIfEmptyAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,7 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
|
|||||||
| DELETE | `/api/admin/maintenance/tickets/closed` | — | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) |
|
| 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/audit-logs` | query: `olderThanDays` (1–3650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней |
|
||||||
| DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` |
|
| DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` |
|
||||||
|
| DELETE | `/api/admin/maintenance/factory-reset` | — | `204 No Content` — полный сброс панели к состоянию свежего деплоя |
|
||||||
|
|
||||||
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
|
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер
|
||||||
удаляет вручную в порядке вложения → комментарии → тикеты.
|
удаляет вручную в порядке вложения → комментарии → тикеты.
|
||||||
@@ -191,6 +192,18 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
|
|||||||
её `CreatedAt` позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет
|
её `CreatedAt` позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет
|
||||||
осознанно — `AuditLog` в проекте append-only, доступна только очистка по возрасту.
|
осознанно — `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
|
## Admin — Activation, Roles
|
||||||
|
|
||||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
|
|||||||
+11
-3
@@ -187,9 +187,17 @@ AppUser
|
|||||||
| `CreatedAt` | `DateTimeOffset` | |
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
Пишется из хендлеров (или обработчиков доменных событий), append-only — из приложения ничего не
|
Пишется из хендлеров (или обработчиков доменных событий), append-only — из приложения ничего не
|
||||||
удаляет и не редактирует записи. Единственное исключение — retention-очистка по возрасту (вкладка
|
удаляет и не редактирует записи. Исключения — retention-очистка по возрасту (вкладка «Обслуживание»,
|
||||||
«Обслуживание», `DELETE /api/admin/maintenance/audit-logs?olderThanDays=N`), доступная только
|
`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)
|
||||||
`AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный).
|
`AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный).
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next)
|
||||||
|
if (!next) setConfirmText('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive">{t('admin.maintenance.factoryReset.action')}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-red-400">{t('admin.maintenance.factoryReset.dialogTitle')}</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<p>{t('admin.maintenance.factoryReset.warningIntro')}</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
{(t('admin.maintenance.factoryReset.warningItems', { returnObjects: true }) as string[]).map(
|
||||||
|
(item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<p className="font-semibold text-red-400">{t('admin.maintenance.factoryReset.irreversible')}</p>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm text-muted-foreground">
|
||||||
|
{t('admin.maintenance.factoryReset.confirmInputLabel', { phrase: confirmPhrase })}
|
||||||
|
</label>
|
||||||
|
<Input value={confirmText} onChange={(e) => setConfirmText(e.target.value)} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||||
|
{t('settings.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={confirmText !== confirmPhrase || mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
>
|
||||||
|
{t('admin.maintenance.factoryReset.confirmButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,3 +14,7 @@ export function deleteOldAuditLogs(olderThanDays: number) {
|
|||||||
export function deleteDisabledApps() {
|
export function deleteDisabledApps() {
|
||||||
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/apps/disabled', { method: 'DELETE' })
|
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/apps/disabled', { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function factoryReset() {
|
||||||
|
return apiRequest<void>('/admin/maintenance/factory-reset', { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api'
|
import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api'
|
||||||
|
import { FactoryResetDialog } from '@/features/admin/maintenance/FactoryResetDialog'
|
||||||
|
|
||||||
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
|
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
|
||||||
|
|
||||||
@@ -118,6 +119,25 @@ function AdminMaintenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<details className="group rounded-md border border-red-900/50">
|
||||||
|
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-red-400">
|
||||||
|
{t('admin.maintenance.factoryReset.dangerZone')}
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-red-900/50 px-4 py-4">
|
||||||
|
<Card className="border-red-900/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-red-400">{t('admin.maintenance.factoryReset.title')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.maintenance.factoryReset.description')}</p>
|
||||||
|
<div>
|
||||||
|
<FactoryResetDialog />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,29 @@ const resources = {
|
|||||||
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
|
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
|
||||||
deleted: 'Удалено приложений: {{count}}.',
|
deleted: 'Удалено приложений: {{count}}.',
|
||||||
},
|
},
|
||||||
|
factoryReset: {
|
||||||
|
dangerZone: 'Опасная зона',
|
||||||
|
title: 'Полный сброс панели',
|
||||||
|
description: 'Вернуть панель в состояние только что развёрнутой — удалить всех пользователей, конфиги, ноды и все данные. Ваш текущий аккаунт сохранится.',
|
||||||
|
action: 'Сбросить панель',
|
||||||
|
dialogTitle: 'Полный сброс панели',
|
||||||
|
warningIntro: 'Будет удалено безвозвратно:',
|
||||||
|
warningItems: [
|
||||||
|
'Все пользователи, кроме вашего текущего аккаунта',
|
||||||
|
'Все VPN-конфиги (сначала будут отозваны на нодах 3x-ui)',
|
||||||
|
'Все ноды и инбаунды — регистрация серверов 3x-ui и сохранённые креды',
|
||||||
|
'Все обращения в поддержку вместе с перепиской и вложениями',
|
||||||
|
'Все новости',
|
||||||
|
'Весь журнал аудита',
|
||||||
|
'Все роли, кроме системных admin/user',
|
||||||
|
'Каталог приложений (будет пересеян из стартового набора)',
|
||||||
|
],
|
||||||
|
irreversible: 'Действие необратимо и не может быть отменено.',
|
||||||
|
confirmPhrase: 'СБРОС',
|
||||||
|
confirmInputLabel: 'Введите «{{phrase}}» для подтверждения',
|
||||||
|
confirmButton: 'Сбросить панель безвозвратно',
|
||||||
|
done: 'Панель сброшена до начального состояния.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
totalUsers: 'Всего пользователей',
|
totalUsers: 'Всего пользователей',
|
||||||
@@ -752,6 +775,29 @@ const resources = {
|
|||||||
confirm: 'Delete all disabled apps? This cannot be undone.',
|
confirm: 'Delete all disabled apps? This cannot be undone.',
|
||||||
deleted: 'Deleted apps: {{count}}.',
|
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: {
|
stats: {
|
||||||
totalUsers: 'Total users',
|
totalUsers: 'Total users',
|
||||||
|
|||||||
Reference in New Issue
Block a user