From 9bae9752cc5ab0e9b133bb6a524980ab175483a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sat, 28 Mar 2026 01:34:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Host/Program.cs | 6 + .../Admin/Commands/BanUser/BanUserCommand.cs | 33 + .../Commands/BanUser/UnbanUserCommand.cs | 33 + .../Commands/CleanRun/CleanRunCommand.cs | 139 +- .../ResetUserPasswordCommand.cs | 1 - .../TestKlipy/TestKlipyConnectionCommand.cs | 7 +- .../Admin/DTOs/AdminUserDetailsDto.cs | 1 + .../Application/Admin/DTOs/AdminUserDto.cs | 3 +- .../Queries/CleanDryRun/CleanDryRunQuery.cs | 142 +- .../GetUserDetails/GetUserDetailsQuery.cs | 38 +- .../Queries/SearchUsers/SearchUsersQuery.cs | 3 +- .../Presentation/Endpoints/AdminEndpoints.cs | 44 +- backend/src/Modules/Auth/Domain/User.cs | 5 + ...20270327160000_AddUserBanField.Designer.cs | 94 + .../20270327160000_AddUserBanField.cs | 31 + .../Migrations/AuthDbContextModelSnapshot.cs | 3 + .../Settings/DTOs/SystemSettingsDto.cs | 1 + .../Application/Abstractions/IKlipyClient.cs | 4 +- .../Modules/Stories/DependencyInjection.cs | 3 + .../Infrastructure/External/KlipyClient.cs | 11 +- .../src/core/infrastructure/httpClient.ts | 4 +- .../admin/presentation/pages/AdminPage.tsx | 1942 ++++++++++------- 22 files changed, 1590 insertions(+), 958 deletions(-) create mode 100644 backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs create mode 100644 backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs create mode 100644 backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.Designer.cs create mode 100644 backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.cs diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index 1b00412..248c5c8 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -108,6 +108,12 @@ builder.Services.AddRouting(options => builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + builder.Services.AddSignalR() .AddJsonProtocol(options => { diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs new file mode 100644 index 0000000..19a1c22 --- /dev/null +++ b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/BanUserCommand.cs @@ -0,0 +1,33 @@ +using Knot.Modules.Auth.Domain; +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Modules.Auth.Application.Abstractions; + +namespace Knot.Modules.Admin.Application.Admin.Commands; + +public record BanUserCommand(Guid UserId) : ICommand; + +internal sealed class BanUserCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IAuthUnitOfWork _unitOfWork; + + public BanUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(BanUserCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found")); + + user.Ban(); + await _unitOfWork.SaveChangesAsync(cancellationToken); + return Result.Success(Result.Success()); + } +} diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs new file mode 100644 index 0000000..629e94d --- /dev/null +++ b/backend/src/Modules/Admin/Application/Admin/Commands/BanUser/UnbanUserCommand.cs @@ -0,0 +1,33 @@ +using Knot.Modules.Auth.Domain; +using System; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Knot.Shared.Kernel; +using Knot.Modules.Auth.Application.Abstractions; + +namespace Knot.Modules.Admin.Application.Admin.Commands; + +public record UnbanUserCommand(Guid UserId) : ICommand; + +internal sealed class UnbanUserCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IAuthUnitOfWork _unitOfWork; + + public UnbanUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(UnbanUserCommand request, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + if (user == null) return Result.Failure(Error.NotFound("User.NotFound", "User not found")); + + user.Unban(); + await _unitOfWork.SaveChangesAsync(cancellationToken); + return Result.Success(Result.Success()); + } +} diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs index 4e055b4..32ed078 100644 --- a/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs +++ b/backend/src/Modules/Admin/Application/Admin/Commands/CleanRun/CleanRunCommand.cs @@ -8,95 +8,106 @@ using System.Threading.Tasks; using MediatR; using Knot.Shared.Kernel; using Knot.Shared.Kernel.Storage; -using Knot.Modules.Auth.Infrastructure.Persistence; -using Knot.Modules.Conversations.Infrastructure.Persistence; -using Knot.Modules.Conversations.Domain; using MongoDB.Driver; using Microsoft.EntityFrameworkCore; +using Knot.Modules.Auth.Infrastructure.Persistence; +using Knot.Modules.Conversations.Infrastructure.Persistence; +using Knot.Modules.Stories.Domain; namespace Knot.Modules.Admin.Application.Admin.Commands; -public record CleanRunCommand(IFileStorageService FileStorage, AuthDbContext IdentityDb) : ICommand; +public record CleanRunCommand() : ICommand; internal sealed class CleanRunCommandHandler : ICommandHandler { private readonly ChatsDbContext _chatsDbContext; private readonly IMongoCollection _messages; + private readonly IMongoCollection _stories; + private readonly IFileStorageService _fileStorage; + private readonly AuthDbContext _identityDb; - public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb) + public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb) { _chatsDbContext = chatsDbContext; _messages = mongoDb.GetCollection("messages"); + _stories = mongoDb.GetCollection("stories"); + _fileStorage = fileStorage; + _identityDb = identityDb; } public async Task> Handle(CleanRunCommand request, CancellationToken cancellationToken) { - var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); - var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); - - var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); - - var orphanMessages = allMessages - .Where(m => !activeChatIds.Contains(m.ChatId)) - .ToList(); - - var keptMessages = allMessages - .Where(m => activeChatIds.Contains(m.ChatId)) - .ToList(); - - var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); - - var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); - - var validUrls = new HashSet(); - - var activeMessageUrls = keptMessages.OfType() - .Where(m => m.Media != null) - .SelectMany(m => m.Media) - .Select(me => me.Url) - .Where(u => !string.IsNullOrEmpty(u)); - - var activeChatUrls = allChats - .Where(c => !string.IsNullOrEmpty(c.Avatar)) - .Select(c => c.Avatar!); - - var activeUserUrls = allUsers - .Where(u => !string.IsNullOrEmpty(u.Avatar)) - .Select(u => u.Avatar!); - - foreach (var u in activeMessageUrls) + try { - validUrls.Add(u!); - } - foreach (var u in activeChatUrls) - { - validUrls.Add(u); - } - foreach (var u in activeUserUrls) - { - validUrls.Add(u); - } + var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); + var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); - var validFileIds = validUrls - .Where(u => u.Contains("/api/files/")) - .Select(u => u.Split('/').Last()) - .ToHashSet(); + var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); - foreach (var file in allMinioFiles) - { - if (!validFileIds.Contains(file.FileId)) + var orphanMessages = allMessages + .Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted) + .ToList(); + + var keptMessages = allMessages + .Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted) + .ToList(); + + var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList(); + + var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken); + + var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken); + + var validUrls = new HashSet(); + + var activeMessageUrls = keptMessages.OfType() + .Where(m => m.Media != null) + .SelectMany(m => m.Media) + .Select(me => me.Url) + .Where(u => !string.IsNullOrEmpty(u)); + + var activeChatUrls = allChats + .Where(c => !string.IsNullOrEmpty(c.Avatar)) + .Select(c => c.Avatar!); + + var activeUserUrls = allUsers + .Where(u => !string.IsNullOrEmpty(u.Avatar)) + .Select(u => u.Avatar!); + + var activeStoryUrls = allStories + .Where(s => !string.IsNullOrEmpty(s.MediaUrl)) + .Select(s => s.MediaUrl!); + + foreach (var u in activeMessageUrls) validUrls.Add(u!); + foreach (var u in activeChatUrls) validUrls.Add(u); + foreach (var u in activeUserUrls) validUrls.Add(u); + foreach (var u in activeStoryUrls) validUrls.Add(u); + + var validFileIds = validUrls + .Where(u => u.Contains("/api/files/")) + .Select(u => u.Split('/').Last()) + .ToHashSet(); + + foreach (var file in allMinioFiles) { - await request.FileStorage.DeleteFileAsync(file.FileId); + if (!validFileIds.Contains(file.FileId)) + { + await _fileStorage.DeleteFileAsync(file.FileId); + } } - } - if (orphanMessages.Any()) + if (orphanMessages.Any()) + { + var orphanIds = orphanMessages.Select(m => m.Id).ToList(); + var filter = Builders.Filter.In(m => m.Id, orphanIds); + await _messages.DeleteManyAsync(filter, cancellationToken); + } + + return Result.Success(new MessageResponse("Cleanup completed successfully")); + } + catch (Exception ex) { - var orphanIds = orphanMessages.Select(m => m.Id).ToList(); - var filter = Builders.Filter.In(m => m.Id, orphanIds); - await _messages.DeleteManyAsync(filter, cancellationToken); + return Result.Failure(new Error("Cleanup.ProcessError", $"Cleanup failed: {ex.Message}")); } - - return Result.Success(new MessageResponse("Cleanup completed successfully")); } } diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs index ecaffe5..ade3e28 100644 --- a/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs +++ b/backend/src/Modules/Admin/Application/Admin/Commands/ResetUserPassword/ResetUserPasswordCommand.cs @@ -8,7 +8,6 @@ using Knot.Shared.Kernel; using Knot.Modules.Auth.Application.Auth.DTOs; using Knot.Modules.Auth.Application.Users; -using Knot.Modules.Auth.Domain; using Knot.Modules.Auth.Infrastructure.Persistence; using Knot.Modules.Auth.Application.Abstractions; diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs index 9999407..7abe326 100644 --- a/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs +++ b/backend/src/Modules/Admin/Application/Admin/Commands/TestKlipy/TestKlipyConnectionCommand.cs @@ -8,7 +8,7 @@ using Knot.Modules.Stories.Application.Abstractions; namespace Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; -public record TestKlipyConnectionCommand() : ICommand; +public record TestKlipyConnectionCommand(string ApiKey, string AppName) : ICommand; internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler { @@ -23,13 +23,12 @@ internal sealed class TestKlipyConnectionCommandHandler : ICommandHandler> Handle(TestKlipyConnectionCommand request, CancellationToken ct) { - var settings = await _settingsService.GetSettingsAsync(ct); - if (string.IsNullOrEmpty(settings.Klipy.ApiKey)) + if (string.IsNullOrEmpty(request.ApiKey)) { return Result.Failure(new Error("Klipy.KeyMissing", "API Key for Klipy is not configured.")); } - var isOk = await _klipyClient.TestConnectionAsync(settings.Klipy.ApiKey, ct); + var isOk = await _klipyClient.TestConnectionAsync(request.ApiKey, request.AppName, ct); if (!isOk) { return Result.Failure(new Error("Klipy.AuthFailed", "Klipy API test request failed. Verify your API key.")); diff --git a/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDetailsDto.cs b/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDetailsDto.cs index 28a0f8d..4f05957 100644 --- a/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDetailsDto.cs +++ b/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDetailsDto.cs @@ -11,5 +11,6 @@ public record AdminUserDetailsDto( DateTime CreatedAt, bool IsOnline, DateTime LastOnlineAt, + bool IsBanned, AdminUserStatsDto Stats ); \ No newline at end of file diff --git a/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs b/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs index 84c70ca..78625af 100644 --- a/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs +++ b/backend/src/Modules/Admin/Application/Admin/DTOs/AdminUserDto.cs @@ -10,5 +10,6 @@ public record AdminUserDto( string? Avatar, DateTime CreatedAt, bool IsOnline, - DateTime LastOnlineAt + DateTime LastOnlineAt, + bool IsBanned ); \ No newline at end of file diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs index 9a66216..44f9101 100644 --- a/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs +++ b/backend/src/Modules/Admin/Application/Admin/Queries/CleanDryRun/CleanDryRunQuery.cs @@ -8,98 +8,108 @@ using System.Threading.Tasks; using MediatR; using Knot.Shared.Kernel; using Knot.Shared.Kernel.Storage; -using Knot.Modules.Auth.Infrastructure.Persistence; -using Knot.Modules.Conversations.Infrastructure.Persistence; -using Knot.Modules.Conversations.Domain; using MongoDB.Driver; using Microsoft.EntityFrameworkCore; +using Knot.Modules.Auth.Infrastructure.Persistence; +using Knot.Modules.Conversations.Infrastructure.Persistence; +using Knot.Modules.Stories.Domain; using Knot.Modules.Auth.Application.Auth.DTOs; - using Knot.Modules.Auth.Application.Users; namespace Knot.Modules.Admin.Application.Admin.Queries; -public record CleanDryRunQuery(IFileStorageService FileStorage, AuthDbContext IdentityDb) : IQuery; +public record CleanDryRunQuery() : IQuery; internal sealed class CleanDryRunQueryHandler : IQueryHandler { private readonly ChatsDbContext _chatsDbContext; private readonly IMongoCollection _messages; + private readonly IMongoCollection _stories; + private readonly IFileStorageService _fileStorage; + private readonly AuthDbContext _identityDb; - public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb) + public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb, IFileStorageService fileStorage, AuthDbContext identityDb) { _chatsDbContext = chatsDbContext; _messages = mongoDb.GetCollection("messages"); + _stories = mongoDb.GetCollection("stories"); + _fileStorage = fileStorage; + _identityDb = identityDb; } public async Task> Handle(CleanDryRunQuery request, CancellationToken cancellationToken) { - var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); - var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); - - var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); - - var orphanedMessages = allMessages - .Where(m => !activeChatIds.Contains(m.ChatId)) - .ToList(); - - var keptMessages = allMessages - .Where(m => activeChatIds.Contains(m.ChatId)) - .ToList(); - - var orphanedMessagesCount = orphanedMessages.Count; - - var allMinioFiles = (await request.FileStorage.ListFilesAsync()).ToList(); - - var allUsers = await request.IdentityDb.Users.AsNoTracking().ToListAsync(cancellationToken); - - var validUrls = new HashSet(); - - var activeMessageUrls = keptMessages.OfType() - .Where(m => m.Media != null) - .SelectMany(m => m.Media) - .Select(me => me.Url) - .Where(u => !string.IsNullOrEmpty(u)); - - var activeChatUrls = allChats - .Where(c => !string.IsNullOrEmpty(c.Avatar)) - .Select(c => c.Avatar!); - - var activeUserUrls = allUsers - .Where(u => !string.IsNullOrEmpty(u.Avatar)) - .Select(u => u.Avatar!); - - foreach (var u in activeMessageUrls) + try { - validUrls.Add(u!); - } - foreach (var u in activeChatUrls) - { - validUrls.Add(u); - } - foreach (var u in activeUserUrls) - { - validUrls.Add(u); - } + var allChats = await _chatsDbContext.Chats.AsNoTracking().ToListAsync(cancellationToken); + var activeChatIds = allChats.Select(c => c.Id).ToHashSet(); - var validFileIds = validUrls - .Where(u => u.Contains("/api/files/")) - .Select(u => u.Split('/').Last()) - .ToHashSet(); + var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken); - long safeBytes = 0; - foreach (var file in allMinioFiles) - { - if (!validFileIds.Contains(file.FileId)) + var orphanedMessages = allMessages + .Where(m => !activeChatIds.Contains(m.ChatId) || m.IsDeleted) + .ToList(); + + var keptMessages = allMessages + .Where(m => activeChatIds.Contains(m.ChatId) && !m.IsDeleted) + .ToList(); + + var orphanedMessagesCount = orphanedMessages.Count; + + var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList(); + + var allUsers = await _identityDb.Users.AsNoTracking().ToListAsync(cancellationToken); + + var allStories = await _stories.Find(_ => true).ToListAsync(cancellationToken); + + var validUrls = new HashSet(); + + var activeMessageUrls = keptMessages.OfType() + .Where(m => m.Media != null) + .SelectMany(m => m.Media) + .Select(me => me.Url) + .Where(u => !string.IsNullOrEmpty(u)); + + var activeChatUrls = allChats + .Where(c => !string.IsNullOrEmpty(c.Avatar)) + .Select(c => c.Avatar!); + + var activeUserUrls = allUsers + .Where(u => !string.IsNullOrEmpty(u.Avatar)) + .Select(u => u.Avatar!); + + var activeStoryUrls = allStories + .Where(s => !string.IsNullOrEmpty(s.MediaUrl)) + .Select(s => s.MediaUrl!); + + foreach (var u in activeMessageUrls) validUrls.Add(u!); + foreach (var u in activeChatUrls) validUrls.Add(u); + foreach (var u in activeUserUrls) validUrls.Add(u); + foreach (var u in activeStoryUrls) validUrls.Add(u); + + var validFileIds = validUrls + .Where(u => u.Contains("/api/files/")) + .Select(u => u.Split('/').Last()) + .ToHashSet(); + + long safeBytes = 0; + foreach (var file in allMinioFiles) { - safeBytes += file.Size; + if (!validFileIds.Contains(file.FileId)) + { + safeBytes += file.Size; + } } - } - return Result.Success(new CleanupDryRunResultDto + return Result.Success(new CleanupDryRunResultDto + { + OrphanedMessagesCount = orphanedMessagesCount, + OrphanedMediaBytes = safeBytes + }); + } + catch (Exception ex) { - OrphanedMessagesCount = orphanedMessagesCount, - OrphanedMediaBytes = safeBytes - }); + return Result.Failure(new Error("Cleanup.ProcessError", $"Failed to analyze junk data: {ex.Message}")); + } } } diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs index c057f47..6274a33 100644 --- a/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs +++ b/backend/src/Modules/Admin/Application/Admin/Queries/GetUserDetails/GetUserDetailsQuery.cs @@ -10,7 +10,6 @@ using Knot.Shared.Kernel; using Knot.Modules.Auth.Application.Auth.DTOs; using Knot.Modules.Auth.Application.Users; -using Knot.Modules.Auth.Domain; using Knot.Modules.Conversations.Domain; using MongoDB.Driver; @@ -37,32 +36,49 @@ internal sealed class GetUserDetailsQueryHandler : IQueryHandler(AuthErrors.UserNotFound); } - var filter = Builders.Filter.Eq(m => m.SenderId, request.UserId); - var userMessages = await _messages.Find(filter).ToListAsync(cancellationToken); + List userMessages; + try + { + var filter = Builders.Filter.Eq(m => m.SenderId, request.UserId); + userMessages = await _messages.Find(filter).ToListAsync(cancellationToken); + } + catch (Exception) + { + userMessages = new List(); + } var messagesCount = userMessages.Count; - var allUserMedia = userMessages.OfType().SelectMany(m => m.Media).ToList(); + var mediaMessages = userMessages.OfType().ToList(); + var allUserMedia = mediaMessages + .Where(m => m.Media != null) + .SelectMany(m => m.Media) + .ToList(); var mediaCount = allUserMedia.Count(m => m.Type == "image" || m.Type == "video"); var filesCount = allUserMedia.Count(m => m.Type == "file" || m.Type == "audio"); var storageUsed = allUserMedia.Sum(m => m.Size ?? 0); - var userContents = userMessages.OfType().Select(m => m.Content) - .Concat(userMessages.OfType().Where(m => m.Caption != null).Select(m => m.Caption)) + var userContents = userMessages.OfType() + .Select(m => m.Content) + .Concat(mediaMessages.Where(m => m.Caption != null).Select(m => m.Caption)) + .Where(c => c != null) .ToList(); - var linksCount = userContents.Count(c => !string.IsNullOrEmpty(c) && c.Contains("http")); + var linksCount = userContents.Count(c => c != null && c.Contains("http", StringComparison.OrdinalIgnoreCase)); + var isOnline = Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()); + var result = new AdminUserDetailsDto( targetUser.Id, - targetUser.Username, - targetUser.DisplayName, + targetUser.Username ?? "unknown", + targetUser.DisplayName ?? "User", targetUser.Bio, targetUser.Avatar, targetUser.CreatedAt, - Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()), - Knot.Modules.Conversations.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt, + isOnline, + isOnline ? DateTime.UtcNow : (targetUser.LastSeen ?? targetUser.CreatedAt), + targetUser.IsBanned, new AdminUserStatsDto( messagesCount, mediaCount, diff --git a/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs b/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs index f9a2f46..0f0fb40 100644 --- a/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs +++ b/backend/src/Modules/Admin/Application/Admin/Queries/SearchUsers/SearchUsersQuery.cs @@ -38,7 +38,8 @@ internal sealed class SearchUsersQueryHandler : IQueryHandler + group.MapPost("settings/test-klipy", async ([FromBody] KlipyTestDto dto, ISender sender, CancellationToken ct) => { - var result = await sender.Send(new TestKlipyConnectionCommand(), ct); + var result = await sender.Send(new TestKlipyConnectionCommand(dto.ApiKey, dto.AppName), ct); return result.IsSuccess ? Results.Ok(new { success = true }) : Results.BadRequest(new { error = result.Error.Description }); }); @@ -67,6 +71,24 @@ public sealed class AdminEndpoints : ICarterModule return Results.Ok(result.Value); }); + group.MapPost("users/{userId:guid}/ban", async (Guid userId, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new BanUserCommand(userId), ct); + return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description }); + }); + + group.MapPost("users/{userId:guid}/unban", async (Guid userId, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new UnbanUserCommand(userId), ct); + return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description }); + }); + + group.MapDelete("users/{userId:guid}", async (Guid userId, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(new DeleteUserCommand(userId), ct); + return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description }); + }); + group.MapGet("users", async (ISender sender, [FromQuery] string query = "", CancellationToken ct = default) => { var result = await sender.Send(new SearchUsersQuery(query), ct); @@ -79,15 +101,25 @@ public sealed class AdminEndpoints : ICarterModule return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found"); }); - group.MapGet("clean/dry-run", async ([FromServices] IFileStorageService fileStorage, [FromServices] AuthDbContext identityDb, ISender sender, CancellationToken ct) => + group.MapGet("clean/dry-run", async (ISender sender, CancellationToken ct) => { - var result = await sender.Send(new CleanDryRunQuery(fileStorage, identityDb), ct); + var result = await sender.Send(new CleanDryRunQuery(), ct); + if (!result.IsSuccess) + { + Console.WriteLine($"[Admin] Cleanup Dry Run Error: {result.Error.Description}"); + return Results.BadRequest(new { error = result.Error.Description }); + } return Results.Ok(result.Value); }); - group.MapPost("clean/run", async ([FromServices] IFileStorageService fileStorage, [FromServices] AuthDbContext identityDb, ISender sender, CancellationToken ct) => + group.MapPost("clean/run", async (ISender sender, CancellationToken ct) => { - var result = await sender.Send(new CleanRunCommand(fileStorage, identityDb), ct); + var result = await sender.Send(new CleanRunCommand(), ct); + if (!result.IsSuccess) + { + Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}"); + return Results.BadRequest(new { error = result.Error.Description }); + } return Results.Ok(result.Value); }); } diff --git a/backend/src/Modules/Auth/Domain/User.cs b/backend/src/Modules/Auth/Domain/User.cs index c98decc..0113326 100644 --- a/backend/src/Modules/Auth/Domain/User.cs +++ b/backend/src/Modules/Auth/Domain/User.cs @@ -24,6 +24,10 @@ public sealed class User : AggregateRoot public bool IsOnline { get; private set; } public DateTime? LastSeen { get; private set; } public bool HideStatus { get; private set; } + public bool IsBanned { get; private set; } + + public void Ban() => IsBanned = true; + public void Unban() => IsBanned = false; private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null) : base(id) @@ -34,6 +38,7 @@ public sealed class User : AggregateRoot Email = email; Bio = bio; CreatedAt = DateTime.UtcNow; + IsBanned = false; } /// diff --git a/backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.Designer.cs b/backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.Designer.cs new file mode 100644 index 0000000..e1dc62f --- /dev/null +++ b/backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.Designer.cs @@ -0,0 +1,94 @@ +// +using System; +using Knot.Modules.Auth.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Knot.Modules.Auth.Migrations +{ + [DbContext(typeof(AuthDbContext))] + [Migration("20270327160000_AddUserBanField")] + partial class AddUserBanField + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("identity") + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("Birthday") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Domain") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("HideStatus") + .HasColumnType("boolean"); + + b.Property("HideStoryViews") + .HasColumnType("boolean"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsExternal") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", "identity"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.cs b/backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.cs new file mode 100644 index 0000000..0e7db48 --- /dev/null +++ b/backend/src/Modules/Auth/Migrations/20270327160000_AddUserBanField.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Knot.Modules.Auth.Migrations +{ + /// + public partial class AddUserBanField : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsBanned", + schema: "identity", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsBanned", + schema: "identity", + table: "Users"); + } + } +} diff --git a/backend/src/Modules/Auth/Migrations/AuthDbContextModelSnapshot.cs b/backend/src/Modules/Auth/Migrations/AuthDbContextModelSnapshot.cs index c68015a..94deca8 100644 --- a/backend/src/Modules/Auth/Migrations/AuthDbContextModelSnapshot.cs +++ b/backend/src/Modules/Auth/Migrations/AuthDbContextModelSnapshot.cs @@ -60,6 +60,9 @@ namespace Knot.Modules.Auth.Migrations b.Property("IsExternal") .HasColumnType("boolean"); + b.Property("IsBanned") + .HasColumnType("boolean"); + b.Property("IsOnline") .HasColumnType("boolean"); diff --git a/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs b/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs index d34cfaf..3bd43cd 100644 --- a/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs +++ b/backend/src/Modules/Settings/Application/Settings/DTOs/SystemSettingsDto.cs @@ -36,6 +36,7 @@ public class MessagesConfig public int ChatMessageLimit { get; set; } = 0; public bool AllowMedia { get; set; } = true; public int MaxMediaSizeBytes { get; set; } = 50 * 1024 * 1024; + public int MaxFileSize { get; set; } = 100 * 1024 * 1024; public List AllowedMediaTypes { get; set; } = new() { "image/jpeg", "image/png", "video/mp4", "image/gif" }; public bool AllowVoiceMessages { get; set; } = true; public bool AllowForwarding { get; set; } = true; diff --git a/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs b/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs index 9a06e0d..cc6da27 100644 --- a/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs +++ b/backend/src/Modules/Stories/Application/Abstractions/IKlipyClient.cs @@ -6,6 +6,6 @@ namespace Knot.Modules.Stories.Application.Abstractions; public interface IKlipyClient { - Task TestConnectionAsync(string apiKey, CancellationToken ct = default); - Task> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default); + Task TestConnectionAsync(string apiKey, string appName, CancellationToken ct = default); + Task> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default); } diff --git a/backend/src/Modules/Stories/DependencyInjection.cs b/backend/src/Modules/Stories/DependencyInjection.cs index 28e48a8..32270e5 100644 --- a/backend/src/Modules/Stories/DependencyInjection.cs +++ b/backend/src/Modules/Stories/DependencyInjection.cs @@ -2,8 +2,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Knot.Modules.Stories.Application.Abstractions; +using Knot.Modules.Stories.Infrastructure.External; using Knot.Modules.Stories.Infrastructure.Persistence.Mongo; using MongoDB.Driver; +using System.Net.Http; namespace Knot.Modules.Stories; @@ -12,6 +14,7 @@ public static class DependencyInjection public static IServiceCollection AddStoriesModule(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); + services.AddHttpClient(); var connectionString = configuration.GetConnectionString("DefaultConnection"); services.AddDbContext(options => diff --git a/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs b/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs index aad4588..676ffcd 100644 --- a/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs +++ b/backend/src/Modules/Stories/Infrastructure/External/KlipyClient.cs @@ -16,12 +16,13 @@ public sealed class KlipyClient : IKlipyClient _httpClient = httpClient; } - public async Task TestConnectionAsync(string apiKey, CancellationToken ct = default) + public async Task TestConnectionAsync(string apiKey, string appName, CancellationToken ct = default) { try { - var request = new HttpRequestMessage(HttpMethod.Get, "https://api.klipy.co/v1/trending?limit=1"); - request.Headers.Add("X-API-KEY", apiKey); + // According to docs: https://api.klipy.com/api/v1/{app_key}/gifs/trending + var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.klipy.com/api/v1/{appName}/gifs/trending?page=1&per_page=1&customer_id=knot_admin_test"); + request.Headers.Add("X-KLIPY-API-KEY", apiKey); using var response = await _httpClient.SendAsync(request, ct); return response.IsSuccessStatusCode; @@ -32,9 +33,9 @@ public sealed class KlipyClient : IKlipyClient } } - public async Task> SearchVideosAsync(string apiKey, string query, int limit, CancellationToken ct = default) + public async Task> SearchVideosAsync(string apiKey, string appName, string query, int limit, CancellationToken ct = default) { - // В реальном проекте: десериализация ответа от Klipy API + // To be implemented as needed return new List(); } } diff --git a/client-web/src/core/infrastructure/httpClient.ts b/client-web/src/core/infrastructure/httpClient.ts index 51dc10c..fd35f40 100644 --- a/client-web/src/core/infrastructure/httpClient.ts +++ b/client-web/src/core/infrastructure/httpClient.ts @@ -15,7 +15,9 @@ export class HttpClient { const isFormData = fetchOptions.body instanceof FormData; const computedHeaders: Record = { - ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + ...(this.token + ? { Authorization: this.token.startsWith('Basic ') ? this.token : `Bearer ${this.token}` } + : {}), ...(fetchOptions.headers as Record), }; diff --git a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx index 8e5a2f9..96a5fce 100644 --- a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx +++ b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx @@ -4,45 +4,126 @@ import { Settings, Shield, Activity, Users, Database, Globe, Search, Trash2, Plu import { httpClient } from '../../../../core/infrastructure/httpClient'; import { deepNormalize } from '../../../../core/utils/normalize'; +interface ActivityStatDto { + date: string; + messages: number; + filesSize: number; +} + +interface TopUserDto { + userId: string; + username: string; + value: number; +} + interface Stats { storageUsedBytes: number; storageLimitBytes: number; onlineUsers: number; offlineUsers: number; totalUsers: number; + activityTimeline: ActivityStatDto[]; + topUsersByMessages: TopUserDto[]; + topUsersByStorage: TopUserDto[]; +} + +interface SystemConfig { + serverTimezone: string; + domainUrl: string; + adminRoute: string; + enableRegistration: boolean; +} + +interface StoriesConfig { + enabled: boolean; + maxStoriesPerPeriod: number; + storyLifetimeHours: number; + textStoriesEnabled: boolean; + textStoryDurationSeconds: number; + mediaStoryMaxDurationSeconds: number; + maxMediaSizeBytes: number; +} + +interface ChatsConfig { + supportGroups: boolean; + maxGroupParticipants: number; + autoCleanChats: boolean; + allowChatToGroupConversion: boolean; + enableFolders: boolean; +} + +interface MessagesConfig { + dailyMessageLimitPerUser: number; + chatMessageLimit: number; + allowMedia: boolean; + maxMediaSizeBytes: number; + maxFileSize: number; + allowedMediaTypes: string[]; + allowVoiceMessages: boolean; + allowForwarding: boolean; + allowReactions: boolean; + allowReplies: boolean; + allowQuoting: boolean; + allowMessageDeletion: boolean; + forbidCopying: boolean; + allowLinks: boolean; + allowPolls: boolean; + allowPinning: boolean; +} + +interface WebRtcConfig { + enabled: boolean; + enableVoiceCalls: boolean; + enableVideoCalls: boolean; + enableScreenSharing: boolean; + turnHost: string; + turnPort: number; + turnUser: string; + turnSecret: string; +} + +interface KlipyConfig { + enabled: boolean; + apiKey: string; + appName: string; +} + +interface ImportConfig { + enableTelegramImport: boolean; +} + +interface FederationDomainConfig { + domain: string; + isEnabled: boolean; +} + +interface FederationConfig { + enabled: boolean; + serverDescription: string; + allowedDomains: FederationDomainConfig[]; } interface Conf { - maxStorageQuotaTb: number; - maxGroupMembers: number; - maxFileSizeMb: number; - enableCalls: boolean; - turnHost: string; - turnPort: number; - // Support both cases - turnUser: string; - turnUsername?: string; - turnSecret: string; - turnPassword?: string; - enableKlipy: boolean; - klipyApiKey: string; - klipyCustomerId: string; - enableConfederation: boolean; - allowedDomains: string[]; - enableRegistration: boolean; + system: SystemConfig; + stories: StoriesConfig; + chats: ChatsConfig; + messages: MessagesConfig; + webRtc: WebRtcConfig; + klipy: KlipyConfig; + import: ImportConfig; + federation: FederationConfig; } interface AppUser { id: string; - userName: string; + username: string; displayName: string; - avatarUrl: string | null; - username?: string; - avatar?: string | null; + avatar: string | null; bio?: string; createdAt: string; lastOnlineAt: string; isOnline?: boolean; + isBanned?: boolean; stats?: { messagesCount: number; mediaCount: number; @@ -54,185 +135,344 @@ interface AppUser { const translations = { en: { - loginTitle: 'Admin Login', + loginTitle: 'Knot Messager Admin', username: 'Username', password: 'Password', - loginBtn: 'Login', - dashboard: 'Dashboard', - settings: 'Settings', + loginBtn: 'Sign In', + dashboard: 'Overview', + settingsGroup: 'Settings', + system: 'System', + stories: 'Stories', + chats: 'Chats', + messages: 'Messages', + webRtc: 'WebRTC', + klipy: 'Klipy', + import: 'Import', federation: 'Federation', - userAnalytics: 'User Analytics', + userAnalytics: 'Users', storageUsed: 'Storage Used', - storageFree: 'Storage Free', - memoryOccupied: 'Memory occupied by user messages & files', - usersOnline: 'Users Online', - usersOffline: 'Users Offline', - totalRegistered: 'total registered', - moduleSettings: 'Module Settings', - limits: 'Limits', - maxFileSize: 'Max File Size (MB)', - maxGroupMembers: 'Max Group Members', - calls: 'WebRTC Calls', - turnHost: 'TURN Host', - turnPort: 'TURN Port', - turnUser: 'TURN User', - turnSecret: 'TURN Password/Secret', - gifs: 'Klipy GIFs', - gifsDesc: 'Allow users to search and send GIFs', - apiKey: 'API Key', + storageFree: 'Free Capacity', + memoryOccupied: 'Disk space consumed by service modules', + usersOnline: 'Online', + usersOffline: 'Offline', + totalRegistered: 'Total Accounts', + limits: 'System Limits', saveChanges: 'Save Changes', - confederationSettings: 'Confederation Settings', - enableConfederation: 'Enable Confederation', - enableConfederationDesc: 'Connect this node with the Knot network', - callsDesc: 'Enable peer-to-peer and group calls', - allowedNodes: 'Allowed Nodes (Whitelist)', - addNode: 'Add Node', + copied: 'Copied', + copy: 'Copy', + copyPassword: 'Copy Password', + backToList: 'Back to List', + searchUsers: 'Search users...', + addUser: 'Add User', + noUsersFound: 'No users found', + registeredOn: 'Registered', + lastSeen: 'Last seen', + resetPassword: 'Reset Password', + deleteUser: 'Delete Account', + banUser: 'Ban User', + unbanUser: 'Unban User', + isBanned: 'Banned', + authSecurity: 'Security', + bio: 'Bio', + messagesSent: 'Messages', + mediaSent: 'Media', + filesSent: 'Files', + linksSent: 'Links', + userStorageOccupied: 'Storage', + displayName: 'Display Name', + cancel: 'Cancel', + createUser: 'Create User', + usernamePlaceholder: 'e.g. jdoe', + displayNamePlaceholder: 'e.g. John Doe', + passwordPlaceholder: 'Min. 8 characters', + generate: 'Generate', + newPassword: 'New Password', + testConnection: 'Test Connection', + apiKey: 'API Key', + appName: 'App Name', + domainUrl: 'Domain URL', + adminPublicPath: 'Admin Path', + enableReg: 'Public Registration', + timezone: 'Timezone', + maxStories: 'Max Stories (24h)', + storyLifetime: 'Lifetime (h)', + textStories: 'Text Stories', + textStoryDuration: 'Text Duration (s)', + mediaStoryDuration: 'Video Duration (s)', + maxMediaSize: 'Max Media (MB)', + supportGroups: 'Group Chats', + maxGroupMembers: 'Max Members', + autoClean: 'Auto Cleanup', + chatToGroup: 'Chat to Group', + enableFolders: 'Folders', + allowDelete: 'Allow Deletion', + maxFileSize: 'Max File (MB)', + dailyLimit: 'Daily Limit', + chatLimit: 'History Limit', + allowMedia: 'Media Attachments', + voiceMessages: 'Voice Messages', + forwarding: 'Forwarding', + reactions: 'Reactions', + replies: 'Replies', + quoting: 'Quoting', + noCopy: 'No Copying', + links: 'Links', + polls: 'Polls', + pinning: 'Pinning', + voiceCalls: 'Voice Calls', + videoCalls: 'Video Calls', + screenSharing: 'Screen Sharing', + turnHost: 'Host', + turnPort: 'Port', + turnUser: 'Username', + turnSecret: 'Secret', + turnServerConfig: 'TURN Configuration', + telegramImport: 'Telegram Import', + serverDesc: 'Server Description', + successSave: 'Settings saved', + errorSave: 'Save failed', + errorInvalidLogin: 'Invalid credentials', + errorDelete: 'Delete failed', + confirmDelete: 'Are you sure? This action is permanent.', + online: 'Online', + offline: 'Offline', + ban: 'Ban', + unban: 'Unban', + allowedNodes: 'Trusted Nodes', + addNode: 'Add', + noNodes: 'No nodes added', importJson: 'Import JSON', exportJson: 'Export JSON', - searchUsers: 'Search Users...', - noUsersFound: 'No users found.', - viewDetails: 'View Details', - backToList: 'Back to list', - profile: 'Profile Details', - registeredOn: 'Registered:', - lastSeen: 'Last seen:', - bio: 'Bio', - errorInvalidLogin: 'Invalid credentials', - successSave: 'Settings saved successfully!', - errorSave: 'Error saving settings', - messagesSent: 'Messages Sent', - mediaSent: 'Media Sent', - filesSent: 'Files Sent', - linksSent: 'Links Sent', - userStorageOccupied: 'Storage Occupied', - authSecurity: 'Auth & Security', - registration: 'User Registration', - registrationDesc: 'Allow new users to create accounts', - addUser: 'Add User', - resetPassword: 'Reset Password', - newPassword: 'New Password', - copyPassword: 'Copy', - copied: 'Copied!', - createUserSuccess: 'User created successfully', - passwordResetSuccess: 'Password reset successfully', - resetPasswordDesc: 'Generate a new strong password for this user and invalidate the old one.', - usernamePlaceholder: 'Ex: johndoe', - displayNamePlaceholder: 'Ex: John Doe', - passwordPlaceholder: 'Strong password...', - generate: 'Generate', - cancel: 'Cancel', - displayName: 'Display Name', - createUserError: 'Error creating user', - createUser: 'Create User', - dataCleanup: 'Data Cleanup', - dataCleanupDesc: 'Calculate and remove unreachable messages (e.g. from deleted groups) and media to free up disk space.', - calcCleanupBtn: 'Calculate GC', - runCleanupBtn: 'Run Clean', - cleanMessages: 'Orphaned Messages', - cleanMedia: 'Orphaned Media Size', - cleanSuccess: 'Cleanup completed successfully!', - testConnection: 'Test Connection', - turnSuccess: 'TURN Connection Successful!', - turnFailed: 'TURN Test Failed: ', - klipySuccess: 'Klipy connection successful!', - klipyFailed: 'Klipy Test Failed: ', - apiRequired: 'API Key is required', - customerIdRequired: 'Customer ID is required', - turnGatherFailed: 'Failed to gather candidates. Check credentials or network.', - turnTimeout: 'Timeout gathering candidates.', + importTitle: 'Data Import', + importDescription: 'Tools for migrating data from external services.', + maintenance: 'Maintenance', + analyzeJunk: 'Analyze Memory', + orphanedMessages: 'Orphaned Messages', + orphanedMedia: 'Orphaned Media', + runCleanup: 'Run Cleanup', + selectUser: 'Select a user', + maxCapacity: 'Max Capacity', + top5: '(Top 5)', + hints: { + domainUrl: 'Primary URL of your instance.', + adminRoute: 'Custom path for admin panel.', + enableRegistration: 'Allow new users to sign up.', + timezone: 'Server time for logs and tasks.', + maxStories: 'Max stories per user in 24h.', + storyLifetime: 'Hours a story remains visible.', + textStoryDuration: 'Display duration for text stories.', + mediaStoryDuration: 'Max duration for video stories.', + maxMediaSize: 'Max file size for story media.', + supportGroups: 'Enable group chat functionality.', + maxGroupMembers: 'Max participants per group.', + autoClean: 'Automatically remove old messages.', + chatToGroup: 'Allow upgrading 1-on-1 chats to groups.', + enableFolders: 'Allow users to use chat folders.', + dailyLimit: 'Max messages per user day (0 = unlimited).', + chatLimit: 'Max messages per chat (0 = unlimited).', + maxFileSize: 'Max size for message attachments.', + noCopy: 'Prevent text copying in clients.', + links: 'Make URLs clickable.', + webRtc: 'Required for real-time calls.', + turn: 'Required for calls behind NAT.', + klipy: 'Integration for stickers and GIFs.', + federation: 'Communication between different Knot instances.', + import: 'Data migration from other services.', + voiceCalls: 'Allow audio calls.', + videoCalls: 'Allow video calls.', + screenSharing: 'Allow screen sharing.', + turnHost: 'TURN server domain or IP.', + turnPort: 'TURN server port.', + turnUser: 'TURN username.', + turnSecret: 'TURN shared secret.', + turnServerConfig: 'WebRTC relay settings.', + appName: 'Klipy App ID.', + apiKey: 'Klipy API Key.', + allowMedia: 'Permit media in messages.', + voiceMessages: 'Permit voice recordings.', + forwarding: 'Permit message relaying.', + reactions: 'Permit emoji reactions.', + replies: 'Permit message threading.', + quoting: 'Permit message quoting.', + allowDelete: 'Allow users to delete messages.', + polls: 'Enable group polls.', + pinning: 'Enable message pinning.' + } }, ru: { - loginTitle: 'Вход администратора', - username: 'Имя пользователя', + loginTitle: 'Knot Messager Admin', + username: 'Логин', password: 'Пароль', loginBtn: 'Войти', - dashboard: 'Дашборд', - settings: 'Настройки', + dashboard: 'Обзор', + settingsGroup: 'Настройки', + system: 'Система', + stories: 'Истории', + chats: 'Чаты', + messages: 'Сообщения', + webRtc: 'WebRTC', + klipy: 'Klipy', + import: 'Импорт', federation: 'Федерация', userAnalytics: 'Пользователи', - storageUsed: 'Места использовано', + storageUsed: 'Занято', storageFree: 'Свободно', - memoryOccupied: 'Память, занятая сообщениями и файлами пользователей', + memoryOccupied: 'Объем данных всех модулей', usersOnline: 'В сети', - usersOffline: 'Не в сети', - totalRegistered: 'всего зарегистрировано', - moduleSettings: 'Настройки модулей', - limits: 'Лимиты', - maxFileSize: 'Макс. размер файла (МБ)', - maxGroupMembers: 'Макс. участников группы', - calls: 'Звонки WebRTC', - turnHost: 'TURN Хост', - turnPort: 'TURN Порт', - turnUser: 'TURN Пользователь', - turnSecret: 'TURN Пароль', - gifs: 'GIF Klipy', - gifsDesc: 'Разрешить пользователям поиск и отправку GIF', - apiKey: 'API Ключ', - saveChanges: 'Сохранить изменения', - confederationSettings: 'Настройки Конфедерации', - enableConfederation: 'Включить Конфедерацию', - enableConfederationDesc: 'Подключить этот узел к сети Knot', - callsDesc: 'Включить P2P и групповые звонки', - allowedNodes: 'Разрешенные узлы (Белый список)', - addNode: 'Добавить узел', + usersOffline: 'Оффлайн', + totalRegistered: 'Всего аккаунтов', + limits: 'Лимиты системы', + saveChanges: 'Сохранить', + copied: 'Скопировано', + copy: 'Копировать', + copyPassword: 'Копировать пароль', + backToList: 'К списку', + searchUsers: 'Поиск...', + addUser: 'Добавить пользователя', + noUsersFound: 'Никого не нашли', + registeredOn: 'Регистрация', + lastSeen: 'Был(а)', + resetPassword: 'Сброс пароля', + deleteUser: 'Удалить аккаунт', + banUser: 'Забанить', + unbanUser: 'Разбанить', + isBanned: 'Забанен', + authSecurity: 'Безопасность', + bio: 'О себе', + messagesSent: 'Сообщения', + mediaSent: 'Медиа', + filesSent: 'Файлы', + linksSent: 'Ссылки', + userStorageOccupied: 'Хранилище', + displayName: 'Имя', + cancel: 'Отмена', + createUser: 'Создать', + usernamePlaceholder: 'логин', + displayNamePlaceholder: 'Имя Фамилия', + passwordPlaceholder: 'от 8 символов', + generate: 'Сгенерировать', + newPassword: 'Новый пароль', + testConnection: 'Проверить', + apiKey: 'API ключ', + appName: 'Имя приложения', + domainUrl: 'URL домена', + adminPublicPath: 'Путь админки', + enableReg: 'Регистрация', + timezone: 'Пояс', + maxStories: 'Макс. историй (24ч)', + storyLifetime: 'Время жизни (ч)', + textStories: 'Текст в историях', + textStoryDuration: 'Длительность текста (сек)', + mediaStoryDuration: 'Длительность видео (сек)', + maxMediaSize: 'Макс. медиа (МБ)', + supportGroups: 'Групповые чаты', + maxGroupMembers: 'Макс. участников', + autoClean: 'Автоочистка', + chatToGroup: 'Чат в группу', + enableFolders: 'Папки', + allowDelete: 'Удаление', + maxFileSize: 'Макс. файл (МБ)', + dailyLimit: 'Дневной лимит', + chatLimit: 'Лимит истории', + allowMedia: 'Медиа в сообщениях', + voiceMessages: 'Голосовые', + forwarding: 'Пересылка', + reactions: 'Реакции', + replies: 'Ответы', + quoting: 'Цитирование', + noCopy: 'Запрет копирования', + links: 'Ссылки', + polls: 'Опросы', + pinning: 'Закрепы', + voiceCalls: 'Аудиозвонки', + videoCalls: 'Видеозвонки', + screenSharing: 'Экран', + turnHost: 'Хост', + turnPort: 'Порт', + turnUser: 'Логин', + turnSecret: 'Секрет', + turnServerConfig: 'TURN сервер', + telegramImport: 'Импорт Telegram', + serverDesc: 'Описание сервера', + successSave: 'Сохранено', + errorSave: 'Ошибка сохранения', + errorInvalidLogin: 'Неверный логин или пароль', + errorDelete: 'Ошибка удаления', + confirmDelete: 'Вы уверены? Это действие нельзя отменить.', + online: 'В сети', + offline: 'Оффлайн', + ban: 'Бан', + unban: 'Разбан', + allowedNodes: 'Доверенные узлы', + addNode: 'Добавить', + noNodes: 'Узлов нет', importJson: 'Импорт JSON', exportJson: 'Экспорт JSON', - searchUsers: 'Поиск пользователей...', - noUsersFound: 'Пользователи не найдены.', - viewDetails: 'Подробнее', - backToList: 'Назад к списку', - profile: 'Профиль пользователя', - registeredOn: 'Зарегистрирован:', - lastSeen: 'Был в сети:', - bio: 'О себе', - errorInvalidLogin: 'Неверные учетные данные', - successSave: 'Настройки успешно сохранены!', - errorSave: 'Ошибка при сохранении', - messagesSent: 'Отправлено сообщений', - mediaSent: 'Отправлено медиа', - filesSent: 'Отправлено файлов', - linksSent: 'Отправлено ссылок', - userStorageOccupied: 'Места на диске', - authSecurity: 'Авторизация', - registration: 'Регистрация пользователей', - registrationDesc: 'Разрешить регистрацию новых аккаунтов', - addUser: 'Добавить пользователя', - resetPassword: 'Сбросить пароль', - newPassword: 'Новый пароль', - copyPassword: 'Копировать', - copied: 'Скопировано!', - createUserSuccess: 'Пользователь успешно создан', - passwordResetSuccess: 'Пароль успешно сброшен', - resetPasswordDesc: 'Сгенерировать новый надежный пароль для этого пользователя и аннулировать старый.', - usernamePlaceholder: 'Например: johndoe', - displayNamePlaceholder: 'Например: Иван Иванов', - passwordPlaceholder: 'Надежный пароль...', - generate: 'Сгенерировать', - cancel: 'Отмена', - displayName: 'Имя профиля', - createUserError: 'Ошибка при создании', - createUser: 'Создать', - dataCleanup: 'Очистка данных', - dataCleanupDesc: 'Вычисление и удаление недостижимых сообщений (например, из удаленных групп) и файлов для освобождения места.', - calcCleanupBtn: 'Оценить объем', - runCleanupBtn: 'Очистить данные', - cleanMessages: 'Сообщений к удалению', - cleanMedia: 'Медиа к удалению', - cleanSuccess: 'Очистка успешно завершена!', - testConnection: 'Проверить соединение', - turnSuccess: 'Успешное подключение к TURN!', - turnFailed: 'Ошибка TURN: ', - klipySuccess: 'Успешное подключение к Klipy!', - klipyFailed: 'Ошибка Klipy: ', - apiRequired: 'API ключ обязателен', - customerIdRequired: 'Customer ID обязателен', - turnGatherFailed: 'Сбор кандидатов не удался. Проверьте данные или сеть.', - turnTimeout: 'Таймаут при сборе кандидатов.', + importTitle: 'Импорт данных', + importDescription: 'Миграция данных из внешних сервисов.', + maintenance: 'Обслуживание', + analyzeJunk: 'Анализ памяти', + orphanedMessages: 'Брошенные сообщения', + orphanedMedia: 'Лишние файлы', + runCleanup: 'Очистить', + selectUser: 'Выберите пользователя', + maxCapacity: 'Лимит диска', + top5: '(Топ 5)', + hints: { + domainUrl: 'Основной адрес сервера.', + adminRoute: 'Путь для входа в админку.', + enableRegistration: 'Разрешить регистрацию новых пользователей.', + timezone: 'Часовой пояс сервера.', + maxStories: 'Лимит историй от одного пользователя за 24 часа.', + storyLifetime: 'Сколько часов история будет видна.', + textStoryDuration: 'Длительность текстовой истории в секундах.', + mediaStoryDuration: 'Лимит видео в историях по времени.', + maxMediaSize: 'Лимит одного файла в историях.', + supportGroups: 'Включить группы.', + maxGroupMembers: 'Максимум людей в одной группе.', + autoClean: 'Удаление старых сообщений.', + chatToGroup: 'Разрешить создавать группы из чатов.', + enableFolders: 'Разрешить папки чатов.', + dailyLimit: 'Лимит сообщений в сутки (0 = без лимита).', + chatLimit: 'Максимум сообщений в чате (0 = без лимита).', + maxFileSize: 'Лимит файлов в сообщениях.', + noCopy: 'Мешать копированию текста.', + links: 'Автоматические ссылки.', + webRtc: 'Нужно для звонков.', + turn: 'Нужно для звонков за NAT.', + klipy: 'Стикеры и GIF.', + federation: 'Связь с другими серверами Knot.', + import: 'Перенос данных извне.', + voiceCalls: 'Разрешить аудиозвонки.', + videoCalls: 'Разрешить видеозвонки.', + screenSharing: 'Разрешить трансляцию экрана.', + turnHost: 'Хост TURN сервера.', + turnPort: 'Порт TURN сервера.', + turnUser: 'Логин TURN.', + turnSecret: 'Секрет TURN.', + turnServerConfig: 'Настройки реле WebRTC.', + appName: 'ID приложения Klipy.', + apiKey: 'Ключ Klipy.', + allowMedia: 'Медиа в чатах.', + voiceMessages: 'Голосовые сообщения.', + forwarding: 'Пересылка сообщений.', + reactions: 'Эмодзи-реакции.', + replies: 'Ветки ответов.', + quoting: 'Цитирование сообщений.', + allowDelete: 'Удаление своих сообщений.', + polls: 'Опросы в группах.', + pinning: 'Закрепление сообщений.' + } } }; +const Hint = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + export default function AdminPage() { const [activeTab, setActiveTab] = useState('dashboard'); + const [activeSettingsTab, setActiveSettingsTab] = useState('system'); const [lang, setLang] = useState<'en' | 'ru'>('ru'); const t = translations[lang]; @@ -252,6 +492,9 @@ export default function AdminPage() { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; + const bytesToMb = (bytes: number) => Math.floor(bytes / (1024 * 1024)); + const mbToBytes = (mb: number) => mb * 1024 * 1024; + interface CleanStats { orphanedMessagesCount: number; orphanedMediaBytes: number; @@ -299,12 +542,12 @@ export default function AdminPage() { method: 'POST', body: JSON.stringify({ ...newUser, email: null, bio: null }) }); - showToast(t.createUserSuccess, 'success'); + showToast(t.successSave, 'success'); setShowAddUserModal(false); setNewUser({ username: '', displayName: '', password: '' }); searchUsers(searchQuery); } catch { - showToast(t.createUserError, 'error'); + showToast(t.errorSave, 'error'); } }; @@ -317,7 +560,45 @@ export default function AdminPage() { }); setGeneratedPass(newPass); } catch { - showToast('Error resetting password', 'error'); + showToast(t.errorSave, 'error'); + } + }; + + const handleBanUser = async (userId: string) => { + try { + await httpClient.request(`/admin/users/${userId}/ban`, { method: 'POST' }); + showToast(t.successSave, 'success'); + if (selectedUser && selectedUser.id === userId) { + setSelectedUser(prev => prev ? { ...prev, isBanned: true } : null); + } + setUsers(users.map(u => u.id === userId ? { ...u, isBanned: true } : u)); + } catch { + showToast(t.errorSave, 'error'); + } + }; + + const handleUnbanUser = async (userId: string) => { + try { + await httpClient.request(`/admin/users/${userId}/unban`, { method: 'POST' }); + showToast(t.successSave, 'success'); + if (selectedUser && selectedUser.id === userId) { + setSelectedUser(prev => prev ? { ...prev, isBanned: false } : null); + } + setUsers(users.map(u => u.id === userId ? { ...u, isBanned: false } : u)); + } catch { + showToast(t.errorSave, 'error'); + } + }; + + const handleDeleteUser = async (userId: string) => { + if (!confirm(t.confirmDelete)) return; + try { + await httpClient.request(`/admin/users/${userId}`, { method: 'DELETE' }); + showToast(t.successSave, 'success'); + setSelectedUser(null); + searchUsers(searchQuery); + } catch { + showToast(t.errorDelete, 'error'); } }; @@ -335,114 +616,169 @@ export default function AdminPage() { } catch {} }; + const saveSettings = async () => { + if (!config) return; + try { + await httpClient.request('/admin/settings', { + method: 'PUT', + body: JSON.stringify(config) + }); + showToast(t.successSave, 'success'); + } catch { showToast(t.errorSave, 'error'); } + }; const handleTestTurn = async () => { - if (!config || !config.turnHost) return; + if (!config || !config.webRtc.turnHost) return; setIsTestingTurn(true); let resolved = false; try { - const turnUrl = `turn:${config.turnHost}:${config.turnPort}`; - const stunUrl = `stun:${config.turnHost}:${config.turnPort}`; + const turnUrl = `turn:${config.webRtc.turnHost}:${config.webRtc.turnPort}`; + const stunUrl = `stun:${config.webRtc.turnHost}:${config.webRtc.turnPort}`; const servers: RTCIceServer[] = [ { urls: stunUrl } ]; - if (config.turnUser) { + if (config.webRtc.turnUser) { servers.push({ urls: [turnUrl, turnUrl + "?transport=tcp"], - username: config.turnUser, - credential: config.turnSecret || config.turnUser + username: config.webRtc.turnUser, + credential: config.webRtc.turnSecret || config.webRtc.turnUser }); } - const pc = new RTCPeerConnection({ - iceServers: servers - }); + const pc = new RTCPeerConnection({ iceServers: servers }); pc.createDataChannel('test'); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - reject(new Error(t.turnTimeout || "Timeout")); - } + if (!resolved) { resolved = true; reject(new Error("Timeout")); } }, 10000); pc.onicecandidate = (e) => { if (e.candidate && (e.candidate.type === 'relay' || e.candidate.type === 'srflx')) { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - resolve(); - } + if (!resolved) { resolved = true; clearTimeout(timeout); resolve(); } } if (e.candidate === null) { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - reject(new Error(t.turnGatherFailed || "Failed to gather candidates.")); - } + if (!resolved) { resolved = true; clearTimeout(timeout); reject(new Error("Failed to gather candidates")); } } }; }); - showToast(t.turnSuccess, 'success'); + showToast("Success", 'success'); pc.close(); } catch (err: any) { - showToast(t.turnFailed + err.message, 'error'); + showToast("Error: " + err.message, 'error'); } finally { setIsTestingTurn(false); } }; const handleTestKlipy = async () => { - if (!config || !config.klipyApiKey) { - showToast(t.apiRequired, 'error'); - return; - } - if (!config.klipyCustomerId) { - showToast(t.customerIdRequired || "Customer ID required", 'error'); + if (!config || !config.klipy.apiKey) { + showToast("API Key is required", 'error'); return; } setIsTestingKlipy(true); try { - const customerId = config.klipyCustomerId || "anonymous"; - let url = `https://api.klipy.co/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`; - - let res = await fetch(url); - if (!res.ok && res.status === 404) { - url = `https://api.klipy.com/api/v1/${config.klipyApiKey}/gifs/trending?page=1&per_page=1&customer_id=${customerId}`; - res = await fetch(url); - } - - if (res.ok) { - const data = await res.json().catch(() => null); - if (data && !data.error && (Array.isArray(data) || data.gifs || data.data)) { - showToast(t.klipySuccess, 'success'); - } else { - showToast(t.klipyFailed + (data?.error || "Invalid response format"), 'error'); - } - } else { - const err = await res.json().catch(() => ({})); - showToast(t.klipyFailed + (err?.error || err?.message || res.statusText), 'error'); - } - } catch (err: any) { - showToast(t.klipyFailed + err.message, 'error'); + await httpClient.request('/admin/settings/test-klipy', { + method: 'POST', + body: JSON.stringify({ + apiKey: config.klipy.apiKey, + appName: config.klipy.appName + }) + }); + showToast("Success", 'success'); + } catch { + showToast("Failed", 'error'); } finally { setIsTestingKlipy(false); } }; + const handleCalcCleanup = async () => { + try { + const res = await httpClient.request('/admin/clean/dry-run'); + setCleanStats(res); + } catch { showToast(t.errorSave, 'error'); } + }; + + const handleRunCleanup = async () => { + try { + await httpClient.request('/admin/clean/run', { method: 'POST' }); + showToast(t.successSave, 'success'); + setCleanStats(null); + fetchDashboard(); + } catch { showToast(t.errorSave, 'error'); } + }; + + const addDomain = () => { + if (!config || !domainInput) return; + const domain = domainInput.trim().toLowerCase(); + const domains = [...config.federation.allowedDomains]; + if (domains.find(d => d.domain === domain)) return; + domains.push({ domain, isEnabled: true }); + setConfig({ ...config, federation: { ...config.federation, allowedDomains: domains } }); + setDomainInput(''); + }; + + const removeDomain = (domain: string) => { + if (!config) return; + const domains = config.federation.allowedDomains.filter(d => d.domain !== domain); + setConfig({ ...config, federation: { ...config.federation, allowedDomains: domains } }); + }; + + const exportDomains = () => { + if (!config) return; + const blob = new Blob([JSON.stringify(config.federation.allowedDomains, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'federation-whitelist.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + const importDomains = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !config) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const data = JSON.parse(ev.target?.result as string); + if (Array.isArray(data)) { + setConfig({ ...config, federation: { ...config.federation, allowedDomains: data } }); + } + } catch { showToast("Invalid JSON", 'error'); } + }; + reader.readAsText(file); + }; + + const searchUsers = async (q: string) => { + setIsSearching(true); + try { + const res = await httpClient.request(`/admin/users?query=${encodeURIComponent(q)}`); + setUsers(res); + } catch {} finally { + setIsSearching(false); + } + }; + + const fetchUserDetails = async (id: string) => { + try { + const res = await httpClient.request(`/admin/users/${id}`); + setSelectedUser(res); + } catch {} + }; + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); const header = `Basic ${btoa(`${creds.user}:${creds.pass}`)}`; - // Store in httpClient - httpClient.setToken(header); // Note: Admin uses Basic auth in this specific screen + httpClient.setToken(header); try { await httpClient.request('/admin/dashboard'); @@ -460,7 +796,6 @@ export default function AdminPage() { useEffect(() => { if (!authenticated || !authHeader) return; const interval = setInterval(() => { - // Refresh dashboard if we are on dashboard tab if (activeTab === 'dashboard') { fetchDashboard(); } @@ -468,91 +803,6 @@ export default function AdminPage() { return () => clearInterval(interval); }, [authenticated, authHeader, activeTab]); - const saveSettings = async () => { - if (!config) return; - try { - await httpClient.request('/admin/settings', { - method: 'PUT', - body: JSON.stringify(config) - }); - showToast(t.successSave, 'success'); - } catch { - showToast(t.errorSave, 'error'); - } - }; - - const searchUsers = async (q: string) => { - setIsSearching(true); - try { - const res = await httpClient.request(`/admin/users?query=${encodeURIComponent(q)}`); - setUsers(res); - } catch {} finally { - setIsSearching(false); - } - }; - - const handleCalcCleanup = async () => { - try { - const res = await httpClient.request('/admin/clean/dry-run'); - setCleanStats(res); - } catch {} - }; - - const handleRunCleanup = async () => { - try { - await httpClient.request('/admin/clean/run', { method: 'POST' }); - showToast(t.cleanSuccess, 'success'); - setCleanStats(null); - fetchDashboard(); - } catch {} - }; - - const fetchUserDetails = async (id: string) => { - try { - const res = await httpClient.request(`/admin/users/${id}`); - setSelectedUser(res); - } catch {} - }; - - // Federation Domain Handlers - const addDomain = () => { - if (domainInput.trim() && config && !config.allowedDomains.includes(domainInput.trim().toLowerCase())) { - setConfig({ ...config, allowedDomains: [...config.allowedDomains, domainInput.trim().toLowerCase()] }); - setDomainInput(''); - } - }; - const removeDomain = (d: string) => { - if (config) { - setConfig({ ...config, allowedDomains: config.allowedDomains.filter(x => x !== d) }); - } - }; - const exportDomains = () => { - if (!config) return; - const blob = new Blob([JSON.stringify(config.allowedDomains, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'knot_allowed_domains.json'; - a.click(); - URL.revokeObjectURL(url); - }; - const importDomains = (e: React.ChangeEvent) => { - if (!e.target.files?.length || !config) return; - const file = e.target.files[0]; - const reader = new FileReader(); - reader.onload = (ev) => { - try { - const parsed = JSON.parse(ev.target?.result as string); - if (Array.isArray(parsed)) { - setConfig({ ...config, allowedDomains: parsed }); - } - } catch { - alert('Invalid JSON file'); - } - }; - reader.readAsText(file); - }; - if (!authenticated) { return (
@@ -585,535 +835,635 @@ export default function AdminPage() { ); } - - - const pct = stats ? Math.min((stats.storageUsedBytes / stats.storageLimitBytes) * 100, 100) : 0; - const freeSpace = stats ? stats.storageLimitBytes - stats.storageUsedBytes : 0; - return (
-
); } -function NavButton({ icon, label, active, onClick }: { icon: React.ReactNode, label: string, active: boolean, onClick: () => void }) { +function NavButton({ icon, label, active, onClick }: { icon: any, label: string, active: boolean, onClick: () => void }) { return ( - ); } function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) { return ( -
onChange(!checked)} - > - -
+ ); }