diff --git a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs index ffaf8ae..639b051 100644 --- a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs @@ -1,6 +1,7 @@ using LiteCqrs; using TeleWave.Api.Common; using TeleWave.Application.Admin.Users.BlockUser; +using TeleWave.Application.Admin.Users.CreateUser; using TeleWave.Application.Admin.Users.DeleteUser; using TeleWave.Application.Admin.Users.GetUser; using TeleWave.Application.Admin.Users.ListUsers; @@ -20,6 +21,7 @@ public static class AdminUserEndpoints .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); admin.MapGet("", ListUsers).Produces>(); + admin.MapPost("", CreateUser).Produces(StatusCodes.Status201Created); admin.MapGet("/{id:guid}", GetUser).Produces(); admin .MapPost("/{id:guid}/block", BlockUser) @@ -49,6 +51,21 @@ public static class AdminUserEndpoints return Results.Ok(result); } + private static async Task CreateUser( + CreateUserBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new CreateUserCommand(body.UserName, body.Password, body.RoleId), + cancellationToken + ); + return result.IsSuccess + ? Results.Created($"/api/admin/users/{result.Value}", new CreatedIdResponse(result.Value)) + : result.ToHttpResult(); + } + private static async Task GetUser( Guid id, ISender sender, @@ -89,3 +106,5 @@ public static class AdminUserEndpoints return result.ToHttpResult(); } } + +public sealed record CreateUserBody(string UserName, string Password, Guid RoleId); diff --git a/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommand.cs b/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommand.cs new file mode 100644 index 0000000..6ed0eb3 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.CreateUser; + +/// Создание пользователя администратором (в обход открытой регистрации), с выбором роли. +public sealed record CreateUserCommand(string UserName, string Password, Guid RoleId) + : ICommand>; diff --git a/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommandHandler.cs b/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommandHandler.cs new file mode 100644 index 0000000..8afa04a --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommandHandler.cs @@ -0,0 +1,40 @@ +using LiteCqrs; +using TeleWave.Application.Admin.Roles; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Admin.Users.CreateUser; + +public sealed class CreateUserCommandHandler( + IIdentityService identityService, + IRoleService roleService +) : ICommandHandler> +{ + public async Task> Handle( + CreateUserCommand command, + CancellationToken cancellationToken + ) + { + // Роль проверяем до создания, чтобы не оставить пользователя с ролью по умолчанию при опечатке. + var roles = await roleService.ListRolesAsync(cancellationToken); + if (roles.All(r => r.Id != command.RoleId)) + return Result.Failure(RoleErrors.NotFound); + + var createResult = await identityService.CreateUserAsync( + command.UserName, + command.Password, + cancellationToken + ); + if (!createResult.IsSuccess) + return Result.Failure(createResult.Error); + + var userId = createResult.Value; + + // CreateUserAsync назначает роль по умолчанию — выставляем выбранную админом. + var roleResult = await roleService.ChangeUserRoleAsync(userId, command.RoleId, cancellationToken); + if (!roleResult.IsSuccess) + return Result.Failure(roleResult.Error); + + return Result.Success(userId); + } +} diff --git a/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommandValidator.cs b/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommandValidator.cs new file mode 100644 index 0000000..50dd4f2 --- /dev/null +++ b/backend/src/TeleWave.Application/Admin/Users/CreateUser/CreateUserCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace TeleWave.Application.Admin.Users.CreateUser; + +public sealed class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8); + RuleFor(x => x.RoleId).NotEmpty(); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Admin/Users/CreateUserCommandHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Admin/Users/CreateUserCommandHandlerTests.cs new file mode 100644 index 0000000..398b283 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Admin/Users/CreateUserCommandHandlerTests.cs @@ -0,0 +1,54 @@ +using NSubstitute; +using TeleWave.Application.Admin.Roles; +using TeleWave.Application.Admin.Users.CreateUser; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using Xunit; + +namespace TeleWave.Application.Tests.Admin.Users; + +public class CreateUserCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IRoleService _roleService = Substitute.For(); + + private CreateUserCommandHandler CreateHandler() => new(_identityService, _roleService); + + [Fact] + public async Task Handle_WithValidRole_CreatesUserAndAssignsRole() + { + var roleId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + _roleService + .ListRolesAsync(Arg.Any()) + .Returns(new List { new(roleId, "user", true) }); + _identityService + .CreateUserAsync("bob", "Password1", Arg.Any()) + .Returns(Result.Success(userId)); + _roleService + .ChangeUserRoleAsync(userId, roleId, Arg.Any()) + .Returns(Result.Success()); + + var result = await CreateHandler() + .Handle(new CreateUserCommand("bob", "Password1", roleId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(userId, result.Value); + await _roleService.Received(1).ChangeUserRoleAsync(userId, roleId, Arg.Any()); + } + + [Fact] + public async Task Handle_WithUnknownRole_ReturnsNotFoundAndDoesNotCreate() + { + _roleService.ListRolesAsync(Arg.Any()).Returns(new List()); + + var result = await CreateHandler() + .Handle(new CreateUserCommand("bob", "Password1", Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(RoleErrors.NotFound, result.Error); + await _identityService + .DidNotReceive() + .CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/frontend/src/features/admin/users/UsersPanel.tsx b/frontend/src/features/admin/users/UsersPanel.tsx index 6137422..02d91cb 100644 --- a/frontend/src/features/admin/users/UsersPanel.tsx +++ b/frontend/src/features/admin/users/UsersPanel.tsx @@ -4,12 +4,14 @@ import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { changeUserRole } from '@/features/admin/roles/api' import { listRoles } from '@/features/admin/roles/api' -import { blockUser, deleteUser, listUsers, unblockUser } from './api' +import { blockUser, createUser, deleteUser, listUsers, unblockUser } from './api' const PAGE_SIZE = 20 @@ -19,6 +21,9 @@ export function UsersPanel() { const [page, setPage] = useState(1) const [search, setSearch] = useState('') const [roleId, setRoleId] = useState('') + const [newUserName, setNewUserName] = useState('') + const [newPassword, setNewPassword] = useState('') + const [newRoleId, setNewRoleId] = useState('') const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles }) const { data, isLoading } = useQuery({ @@ -34,18 +39,90 @@ export function UsersPanel() { const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError }) const deleteMutation = useMutation({ mutationFn: deleteUser, onSuccess: invalidate, onError }) const changeRoleMutation = useMutation({ - mutationFn: ({ userId, roleId: newRoleId }: { userId: string; roleId: string }) => - changeUserRole(userId, newRoleId), + mutationFn: ({ userId, roleId: nextRoleId }: { userId: string; roleId: string }) => + changeUserRole(userId, nextRoleId), onSuccess: invalidate, onError, }) + // Роль по умолчанию для новой учётки — «user» (если есть), иначе первая роль. + const defaultRoleId = roles?.find((r) => r.name === 'user')?.id ?? roles?.[0]?.id ?? '' + const effectiveNewRoleId = newRoleId || defaultRoleId + + const createMutation = useMutation({ + mutationFn: () => + createUser({ + userName: newUserName.trim(), + password: newPassword, + roleId: effectiveNewRoleId, + }), + onSuccess: () => { + setNewUserName('') + setNewPassword('') + setNewRoleId('') + toast.success(t('admin.users.created')) + invalidate() + }, + onError, + }) + + const canCreate = + newUserName.trim().length >= 3 && newPassword.length >= 8 && effectiveNewRoleId !== '' + const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1 return (

{t('admin.users.title')}

+ + + {t('admin.users.createTitle')} + + +
+ + setNewUserName(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + +
+ +

{t('admin.users.passwordHint')}

+
+
+
>(`/admin/users?${query.toString()}`) } +export function createUser(body: { userName: string; password: string; roleId: string }) { + return apiRequest('/admin/users', { method: 'POST', body }) +} + export function blockUser(id: string) { return apiRequest(`/admin/users/${id}/block`, { method: 'POST' }) } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 564f629..54798f4 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -105,6 +105,11 @@ const resources = { block: 'Заблокировать', unblock: 'Разблокировать', filterAll: 'Все роли', + createTitle: 'Создать пользователя', + password: 'Пароль', + passwordHint: 'Минимум 8 символов, хотя бы одна цифра и заглавная буква.', + create: 'Создать', + created: 'Пользователь создан', }, media: { title: 'Медиа', @@ -344,6 +349,11 @@ const resources = { block: 'Block', unblock: 'Unblock', filterAll: 'All roles', + createTitle: 'Create user', + password: 'Password', + passwordHint: 'At least 8 characters, including a digit and an uppercase letter.', + create: 'Create', + created: 'User created', }, media: { title: 'Media',