diff --git a/backend/src/Contracts/Conversations/Abstractions/IChatQueryService.cs b/backend/src/Contracts/Conversations/Abstractions/IChatQueryService.cs index a809f32..d3eafcc 100644 --- a/backend/src/Contracts/Conversations/Abstractions/IChatQueryService.cs +++ b/backend/src/Contracts/Conversations/Abstractions/IChatQueryService.cs @@ -10,9 +10,4 @@ public interface IChatQueryService Task> GetAllChatsAsync(CancellationToken cancellationToken); } -public interface IUserDeleterService -{ - Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken); -} - public record ChatInfo(Guid Id, string? Avatar); diff --git a/backend/src/Contracts/Conversations/Abstractions/IUserDeleterService.cs b/backend/src/Contracts/Conversations/Abstractions/IUserDeleterService.cs new file mode 100644 index 0000000..972749a --- /dev/null +++ b/backend/src/Contracts/Conversations/Abstractions/IUserDeleterService.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Conversations.Abstractions; + +public interface IUserDeleterService +{ + Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/backend/src/Modules/Admin/Application/Admin/Commands/CreateUser/CreateUserCommand.cs b/backend/src/Modules/Admin/Application/Admin/Commands/CreateUser/CreateUserCommand.cs new file mode 100644 index 0000000..a301137 --- /dev/null +++ b/backend/src/Modules/Admin/Application/Admin/Commands/CreateUser/CreateUserCommand.cs @@ -0,0 +1,68 @@ +using BCrypt.Net; +using Knot.Contracts.Auth.Application.Abstractions; +using Knot.Contracts.Auth.Domain; +using Knot.Modules.Admin.Application.Admin.DTOs; +using Knot.Shared.Kernel; +using MediatR; + +namespace Knot.Modules.Admin.Application.Admin.Commands.CreateUser; + +public record CreateUserCommand( + string Username, + string Password, + string DisplayName, + string? Email = null, + string? Bio = null +) : ICommand; + +internal sealed class CreateUserCommandHandler : ICommandHandler +{ + private readonly IUserRepository _userRepository; + private readonly IAuthUnitOfWork _unitOfWork; + + public CreateUserCommandHandler(IUserRepository userRepository, IAuthUnitOfWork unitOfWork) + { + _userRepository = userRepository; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + // Проверка уникальности username + if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken)) + { + return Result.Failure(new Error("Admin.CreateUser.UsernameNotUnique", "Username is already taken")); + } + + // Хеширование пароля + string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); + + // Создание контракта пользователя + var userContract = new UserContract + { + Id = Guid.NewGuid(), + Username = request.Username, + PasswordHash = passwordHash, + DisplayName = request.DisplayName, + Email = request.Email, + Bio = request.Bio, + CreatedAt = DateTime.UtcNow, + IsBanned = false, + IsOnline = false + }; + + _unitOfWork.Add(userContract); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(new AdminUserDto( + userContract.Id, + userContract.Username, + userContract.DisplayName, + userContract.Email, + userContract.Avatar, + userContract.CreatedAt, + userContract.IsOnline, + userContract.LastSeen ?? DateTime.UtcNow, + userContract.IsBanned)); + } +} diff --git a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj index f428a51..3af130f 100644 --- a/backend/src/Modules/Admin/Knot.Modules.Admin.csproj +++ b/backend/src/Modules/Admin/Knot.Modules.Admin.csproj @@ -18,7 +18,7 @@ - + \ No newline at end of file diff --git a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs index bf4d351..86fc2b0 100644 --- a/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs +++ b/backend/src/Modules/Admin/Presentation/Endpoints/AdminEndpoints.cs @@ -1,7 +1,9 @@ using System.Text.Json.Serialization; +using Knot.Contracts.Conversations.Abstractions; using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.DTOs; using Knot.Modules.Admin.Application.Admin.Commands; +using Knot.Modules.Admin.Application.Admin.Commands.CreateUser; using Knot.Modules.Admin.Application.Admin.Commands.TestKlipy; using Knot.Modules.Admin.Application.Admin.Queries; using MediatR; @@ -56,6 +58,12 @@ public static class AdminEndpoints return Results.Ok(result.Value); }); + group.MapPost("users", async ([FromBody] CreateUserCommand command, ISender sender, CancellationToken ct) => + { + var result = await sender.Send(command, ct); + return result.IsSuccess ? Results.Created($"/api/admin/users/{result.Value.Id}", result.Value) : Results.BadRequest(new { error = result.Error.Description }); + }); + group.MapPost("users/{userId:guid}/reset-password", async ([FromRoute] Guid userId, [FromBody] ResetPasswordRequest dto, ISender sender, CancellationToken ct) => { var result = await sender.Send(new ResetUserPasswordCommand(userId, dto.NewPassword), ct); @@ -106,6 +114,12 @@ public static class AdminEndpoints return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound("User not found"); }); + group.MapDelete("users/{userId:guid}", async ([FromRoute] Guid userId, IUserDeleterService userDeleter, CancellationToken ct) => + { + var result = await userDeleter.DeleteUserAsync(userId, ct); + return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description }); + }); + group.MapGet("clean/dry-run", async (ISender sender, CancellationToken ct) => { var result = await sender.Send(new CleanDryRunQuery(), ct); diff --git a/backend/src/Modules/Conversations/DependencyInjection.cs b/backend/src/Modules/Conversations/DependencyInjection.cs index 55b264d..9f23bd0 100644 --- a/backend/src/Modules/Conversations/DependencyInjection.cs +++ b/backend/src/Modules/Conversations/DependencyInjection.cs @@ -43,6 +43,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatsDbContextFactory.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatsDbContextFactory.cs new file mode 100644 index 0000000..9ae127f --- /dev/null +++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/ChatsDbContextFactory.cs @@ -0,0 +1,17 @@ +using Knot.Modules.Conversations.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Knot.Modules.Conversations.Infrastructure; + +[DbContext(typeof(ChatsDbContext))] +public class ChatsDbContextFactory : IDesignTimeDbContextFactory +{ + public ChatsDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql("Host=localhost;Database=knot_db;Username=knot;Password=knot_pass"); + return new ChatsDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs b/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs index 3721f3f..6ec5a42 100644 --- a/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs +++ b/backend/src/Modules/Conversations/Infrastructure/Persistence/ConversationsDbContext.cs @@ -18,11 +18,16 @@ namespace Knot.Modules.Conversations.Infrastructure.Persistence; public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext { - private readonly IMediator _mediator; - private readonly IEncryptionService _encryptionService; + private readonly IMediator? _mediator; + private readonly IEncryptionService? _encryptionService; + + public ChatsDbContext(DbContextOptions options) + : base(options) + { + } public ChatsDbContext(DbContextOptions options, IMediator mediator, IEncryptionService encryptionService) - : base(options) + : this(options) { _mediator = mediator; _encryptionService = encryptionService; @@ -99,9 +104,12 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli int result = await base.SaveChangesAsync(cancellationToken); - foreach (var domainEvent in domainEvents) + if (_mediator != null) { - await _mediator.Publish(domainEvent, cancellationToken); + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent, cancellationToken); + } } return result; diff --git a/backend/src/Modules/Conversations/Infrastructure/Services/UserDeleterService.cs b/backend/src/Modules/Conversations/Infrastructure/Services/UserDeleterService.cs new file mode 100644 index 0000000..73ceced --- /dev/null +++ b/backend/src/Modules/Conversations/Infrastructure/Services/UserDeleterService.cs @@ -0,0 +1,21 @@ +using Knot.Contracts.Conversations.Abstractions; +using Knot.Modules.Conversations.Application.Users.Commands.DeleteUser; +using Knot.Shared.Kernel; +using MediatR; + +namespace Knot.Modules.Conversations.Infrastructure.Services; + +public sealed class UserDeleterService : IUserDeleterService +{ + private readonly ISender _sender; + + public UserDeleterService(ISender sender) + { + _sender = sender; + } + + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) + { + return await _sender.Send(new DeleteUserCommand(userId), cancellationToken); + } +} \ No newline at end of file diff --git a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj index d41b490..e2be4c1 100644 --- a/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj +++ b/backend/src/Modules/Conversations/Knot.Modules.Conversations.csproj @@ -19,6 +19,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/backend/src/Modules/Conversations/Migrations/ChatsDbContextModelSnapshot.cs b/backend/src/Modules/Conversations/Migrations/ChatsDbContextModelSnapshot.cs index bd5302c..b0df6c5 100644 --- a/backend/src/Modules/Conversations/Migrations/ChatsDbContextModelSnapshot.cs +++ b/backend/src/Modules/Conversations/Migrations/ChatsDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Knot.Modules.Conversations.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; diff --git a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx index d0d4eaa..a3472d0 100644 --- a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx +++ b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx @@ -543,6 +543,9 @@ export default function AdminPage() { const [newUser, setNewUser] = useState({ username: '', displayName: '', password: '' }); const [generatedPass, setGeneratedPass] = useState(''); + // Delete User Modal State + const [deleteUserModal, setDeleteUserModal] = useState<{ userId: string; username: string } | null>(null); + const [timezones, setTimezones] = useState([]); const [tzSearch, setTzSearch] = useState(''); const [showTzDropdown, setShowTzDropdown] = useState(false); @@ -616,7 +619,7 @@ export default function AdminPage() { }; const handleDeleteUser = async (userId: string) => { - if (!confirm(t.confirmDelete)) return; + setDeleteUserModal(null); try { await httpClient.request(`/admin/users/${userId}`, { method: 'DELETE' }); showToast(t.successSave, 'success'); @@ -843,7 +846,7 @@ export default function AdminPage() {
hub
- +

{t.loginTitle}

Administrator

@@ -889,7 +892,7 @@ export default function AdminPage() { {t.loginBtn} arrow_forward - +
@@ -1440,14 +1443,13 @@ export default function AdminPage() {
{users.length === 0 && !isSearching &&
{t.noUsersFound}
} {users.map(u => ( - )} -
@@ -1566,7 +1568,7 @@ export default function AdminPage() {
)} + + {/* Delete User Confirmation Modal */} + + {deleteUserModal && ( + setDeleteUserModal(null)} + > + e.stopPropagation()} + > +
+
+ warning +
+
+

{lang === 'ru' ? 'Удаление пользователя' : 'Delete User'}

+

{lang === 'ru' ? 'Это действие необратимо' : 'This action is irreversible'}

+
+
+ +
+

{lang === 'ru' ? 'Вы собираетесь удалить:' : 'You are about to delete:'}

+

@{deleteUserModal.username}

+
+ +

+ {t.confirmDelete} +

+ +
+ + +
+
+
+ )} +
); } function NavButton({ icon, label, active, onClick }: { icon: string, label: string, active: boolean, onClick: () => void }) { return ( - ); diff --git a/client-web/src/modules/auth/infrastructure/authApi.ts b/client-web/src/modules/auth/infrastructure/authApi.ts index d3bd186..fc1ae46 100644 --- a/client-web/src/modules/auth/infrastructure/authApi.ts +++ b/client-web/src/modules/auth/infrastructure/authApi.ts @@ -3,17 +3,37 @@ import type { User } from '../../../core/domain/types'; export class AuthApi { static async login(username: string, password: string) { - return httpClient.request<{ token: string; user: User }>('/auth/login', { + const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }); + + return { + token: response.accessToken, + user: { + id: response.userId, + username: response.username, + displayName: response.displayName, + avatar: null + } as User + }; } static async register(username: string, displayName: string, password: string, bio?: string) { - return httpClient.request<{ token: string; user: User }>('/auth/register', { + const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/register', { method: 'POST', body: JSON.stringify({ username, displayName, password, bio }), }); + + return { + token: response.accessToken, + user: { + id: response.userId, + username: response.username, + displayName: response.displayName, + avatar: null + } as User + }; } static async getMe() {