Initial commit: base slice (auth, roles, users, admin) scaffold
Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL + Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4 with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's conventions, scoped down to the current base feature set.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
|
||||
|
||||
public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand<Result>;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
|
||||
|
||||
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<ChangeUserRoleCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken) =>
|
||||
roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.CreateRole;
|
||||
|
||||
public sealed record CreateRoleCommand(string Name) : ICommand<Result<RoleDto>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.CreateRole;
|
||||
|
||||
public sealed class CreateRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
|
||||
{
|
||||
public Task<Result<RoleDto>> Handle(
|
||||
CreateRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
) => roleService.CreateRoleAsync(command.Name, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.CreateRole;
|
||||
|
||||
public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
|
||||
{
|
||||
public CreateRoleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.DeleteRole;
|
||||
|
||||
public sealed record DeleteRoleCommand(Guid Id) : ICommand<Result>;
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.DeleteRole;
|
||||
|
||||
public sealed class DeleteRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<DeleteRoleCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
|
||||
roleService.DeleteRoleAsync(command.Id, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.ListRoles;
|
||||
|
||||
public sealed record ListRolesQuery : IQuery<IReadOnlyList<RoleDto>>;
|
||||
@@ -0,0 +1,13 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.ListRoles;
|
||||
|
||||
public sealed class ListRolesQueryHandler(IRoleService roleService)
|
||||
: IQueryHandler<ListRolesQuery, IReadOnlyList<RoleDto>>
|
||||
{
|
||||
public Task<IReadOnlyList<RoleDto>> Handle(
|
||||
ListRolesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
) => roleService.ListRolesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles;
|
||||
|
||||
public static class RoleErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
|
||||
|
||||
public static readonly Error DuplicateName = Error.Conflict(
|
||||
"Roles.DuplicateName",
|
||||
"Роль с таким именем уже существует."
|
||||
);
|
||||
|
||||
public static readonly Error CannotModifySystemRole = Error.Forbidden(
|
||||
"Roles.CannotModifySystemRole",
|
||||
"Системную роль нельзя переименовать или удалить."
|
||||
);
|
||||
|
||||
public static readonly Error RoleInUse = Error.Conflict(
|
||||
"Roles.RoleInUse",
|
||||
"Роль назначена пользователям — сначала смените им роль."
|
||||
);
|
||||
|
||||
public static readonly Error CannotRemoveLastAdmin = Error.Conflict(
|
||||
"Roles.CannotRemoveLastAdmin",
|
||||
"Нельзя снять роль admin с последнего администратора."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.UpdateRole;
|
||||
|
||||
public sealed record UpdateRoleCommand(Guid Id, string Name) : ICommand<Result<RoleDto>>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.UpdateRole;
|
||||
|
||||
public sealed class UpdateRoleCommandHandler(IRoleService roleService)
|
||||
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
|
||||
{
|
||||
public Task<Result<RoleDto>> Handle(
|
||||
UpdateRoleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
) => roleService.UpdateRoleAsync(command.Id, command.Name, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Admin.Roles.UpdateRole;
|
||||
|
||||
public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
|
||||
{
|
||||
public UpdateRoleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.BlockUser;
|
||||
|
||||
public sealed record BlockUserCommand(Guid UserId) : ICommand<Result>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.BlockUser;
|
||||
|
||||
public sealed class BlockUserCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<BlockUserCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId == command.UserId)
|
||||
return Task.FromResult(Result.Failure(UserErrors.CannotBlockSelf));
|
||||
|
||||
return identityService.BlockUserAsync(command.UserId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.DeleteUser;
|
||||
|
||||
public sealed record DeleteUserCommand(Guid UserId) : ICommand<Result>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.DeleteUser;
|
||||
|
||||
public sealed class DeleteUserCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteUserCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId == command.UserId)
|
||||
return Task.FromResult(Result.Failure(UserErrors.CannotDeleteSelf));
|
||||
|
||||
return identityService.DeleteUserAsync(command.UserId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.GetUser;
|
||||
|
||||
public sealed record GetUserQuery(Guid Id) : IQuery<Result<UserSummaryDto>>;
|
||||
@@ -0,0 +1,20 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.GetUser;
|
||||
|
||||
public sealed class GetUserQueryHandler(IIdentityService identityService)
|
||||
: IQueryHandler<GetUserQuery, Result<UserSummaryDto>>
|
||||
{
|
||||
public async Task<Result<UserSummaryDto>> Handle(
|
||||
GetUserQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var user = await identityService.GetUserAsync(query.Id, cancellationToken);
|
||||
return user is null
|
||||
? Result.Failure<UserSummaryDto>(UserErrors.NotFound)
|
||||
: Result.Success(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.ListUsers;
|
||||
|
||||
public sealed record ListUsersQuery(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string? Search,
|
||||
Guid? RoleId,
|
||||
bool? IsBlocked
|
||||
) : IQuery<PagedList<UserSummaryDto>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.ListUsers;
|
||||
|
||||
public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
||||
: IQueryHandler<ListUsersQuery, PagedList<UserSummaryDto>>
|
||||
{
|
||||
public Task<PagedList<UserSummaryDto>> Handle(
|
||||
ListUsersQuery query,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
identityService.ListUsersAsync(
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.Search,
|
||||
query.RoleId,
|
||||
query.IsBlocked,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.UnblockUser;
|
||||
|
||||
public sealed record UnblockUserCommand(Guid UserId) : ICommand<Result>;
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users.UnblockUser;
|
||||
|
||||
public sealed class UnblockUserCommandHandler(IIdentityService identityService)
|
||||
: ICommandHandler<UnblockUserCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken) =>
|
||||
identityService.UnblockUserAsync(command.UserId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Admin.Users;
|
||||
|
||||
public static class UserErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден.");
|
||||
|
||||
public static readonly Error CannotDeleteSelf = Error.Forbidden(
|
||||
"Users.CannotDeleteSelf",
|
||||
"Нельзя удалить собственный аккаунт через админку."
|
||||
);
|
||||
|
||||
public static readonly Error CannotBlockSelf = Error.Forbidden(
|
||||
"Users.CannotBlockSelf",
|
||||
"Нельзя заблокировать собственный аккаунт."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth;
|
||||
|
||||
public static class AuthErrors
|
||||
{
|
||||
public static readonly Error InvalidCredentials =
|
||||
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль.");
|
||||
|
||||
public static readonly Error Unauthorized = Error.Unauthorized(
|
||||
"Auth.Unauthorized",
|
||||
"Требуется вход в систему."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
|
||||
"Auth.InvalidRefreshToken",
|
||||
"Сессия истекла, войдите заново."
|
||||
);
|
||||
|
||||
public static readonly Error Blocked = Error.Forbidden(
|
||||
"Auth.Blocked",
|
||||
"Аккаунт заблокирован администратором."
|
||||
);
|
||||
|
||||
public static readonly Error UserNameTaken = Error.Conflict(
|
||||
"Auth.UserNameTaken",
|
||||
"Это имя пользователя уже занято."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TeleWave.Application.Auth;
|
||||
|
||||
public sealed record CurrentUserDto(Guid Id, string UserName, string Role);
|
||||
|
||||
public sealed record AuthResult(
|
||||
string AccessToken,
|
||||
DateTimeOffset AccessTokenExpiresAt,
|
||||
string RefreshToken,
|
||||
DateTimeOffset RefreshTokenExpiresAt,
|
||||
CurrentUserDto User
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.ChangePassword;
|
||||
|
||||
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand<Result>;
|
||||
@@ -0,0 +1,27 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.ChangePassword;
|
||||
|
||||
public sealed class ChangePasswordCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ChangePasswordCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ChangePasswordCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
return await identityService.ChangePasswordAsync(
|
||||
userId,
|
||||
command.CurrentPassword,
|
||||
command.NewPassword,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Auth.ChangePassword;
|
||||
|
||||
public sealed class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
|
||||
{
|
||||
public ChangePasswordCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.CurrentPassword).NotEmpty();
|
||||
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.ChangeUserName;
|
||||
|
||||
public sealed record ChangeUserNameCommand(string NewUserName) : ICommand<Result>;
|
||||
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.ChangeUserName;
|
||||
|
||||
public sealed class ChangeUserNameCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ChangeUserNameCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ChangeUserNameCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
return await identityService.ChangeUserNameAsync(
|
||||
userId,
|
||||
command.NewUserName,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Auth.ChangeUserName;
|
||||
|
||||
public sealed class ChangeUserNameCommandValidator : AbstractValidator<ChangeUserNameCommand>
|
||||
{
|
||||
public ChangeUserNameCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NewUserName).NotEmpty().MinimumLength(3).MaximumLength(64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.DeleteMyAccount;
|
||||
|
||||
public sealed record DeleteMyAccountCommand : ICommand<Result>;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.DeleteMyAccount;
|
||||
|
||||
public sealed class DeleteMyAccountCommandHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteMyAccountCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteMyAccountCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
return await identityService.DeleteUserAsync(userId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Login;
|
||||
|
||||
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
|
||||
@@ -0,0 +1,50 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Login;
|
||||
|
||||
public sealed class LoginCommandHandler(
|
||||
IIdentityService identityService,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenService refreshTokenService
|
||||
) : ICommandHandler<LoginCommand, Result<AuthResult>>
|
||||
{
|
||||
public async Task<Result<AuthResult>> Handle(
|
||||
LoginCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var credentialsResult = await identityService.ValidateCredentialsAsync(
|
||||
command.UserName,
|
||||
command.Password,
|
||||
cancellationToken
|
||||
);
|
||||
if (!credentialsResult.IsSuccess)
|
||||
return Result.Failure<AuthResult>(credentialsResult.Error);
|
||||
|
||||
var user = credentialsResult.Value;
|
||||
var profile = await identityService.GetProfileAsync(user.Id, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<AuthResult>(AuthErrors.InvalidCredentials);
|
||||
|
||||
if (profile.IsBlocked)
|
||||
return Result.Failure<AuthResult>(AuthErrors.Blocked);
|
||||
|
||||
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
|
||||
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
|
||||
);
|
||||
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
|
||||
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
|
||||
return Result.Success(
|
||||
new AuthResult(
|
||||
accessToken,
|
||||
accessExpiresAt,
|
||||
refreshToken.RawToken,
|
||||
refreshToken.ExpiresAt,
|
||||
dto
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Auth.Login;
|
||||
|
||||
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
|
||||
{
|
||||
public LoginCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserName).NotEmpty();
|
||||
RuleFor(x => x.Password).NotEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Logout;
|
||||
|
||||
public sealed record LogoutCommand(string RawToken) : ICommand<Result>;
|
||||
@@ -0,0 +1,15 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Logout;
|
||||
|
||||
public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService)
|
||||
: ICommandHandler<LogoutCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(LogoutCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await refreshTokenService.RevokeAsync(command.RawToken, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Me;
|
||||
|
||||
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
|
||||
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Me;
|
||||
|
||||
public sealed class GetCurrentUserQueryHandler(
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
|
||||
{
|
||||
public async Task<Result<CurrentUserDto>> Handle(
|
||||
GetCurrentUserQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
|
||||
|
||||
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Refresh;
|
||||
|
||||
public sealed record RefreshCommand(string RawToken) : ICommand<Result<AuthResult>>;
|
||||
@@ -0,0 +1,44 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Refresh;
|
||||
|
||||
public sealed class RefreshCommandHandler(
|
||||
IRefreshTokenService refreshTokenService,
|
||||
IIdentityService identityService,
|
||||
IJwtTokenService jwtTokenService
|
||||
) : ICommandHandler<RefreshCommand, Result<AuthResult>>
|
||||
{
|
||||
public async Task<Result<AuthResult>> Handle(
|
||||
RefreshCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var rotated = await refreshTokenService.RotateAsync(command.RawToken, cancellationToken);
|
||||
if (!rotated.IsSuccess)
|
||||
return Result.Failure<AuthResult>(rotated.Error);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(
|
||||
rotated.Value.UserId,
|
||||
cancellationToken
|
||||
);
|
||||
if (profile is null || profile.IsBlocked)
|
||||
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
|
||||
|
||||
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
|
||||
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
|
||||
);
|
||||
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
|
||||
return Result.Success(
|
||||
new AuthResult(
|
||||
accessToken,
|
||||
accessExpiresAt,
|
||||
rotated.Value.RawToken,
|
||||
rotated.Value.ExpiresAt,
|
||||
dto
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Register;
|
||||
|
||||
public sealed record RegisterCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
|
||||
@@ -0,0 +1,46 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Auth.Register;
|
||||
|
||||
public sealed class RegisterCommandHandler(
|
||||
IIdentityService identityService,
|
||||
IJwtTokenService jwtTokenService,
|
||||
IRefreshTokenService refreshTokenService
|
||||
) : ICommandHandler<RegisterCommand, Result<AuthResult>>
|
||||
{
|
||||
public async Task<Result<AuthResult>> Handle(
|
||||
RegisterCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var createResult = await identityService.CreateUserAsync(
|
||||
command.UserName,
|
||||
command.Password,
|
||||
cancellationToken
|
||||
);
|
||||
if (!createResult.IsSuccess)
|
||||
return Result.Failure<AuthResult>(createResult.Error);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(createResult.Value, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
|
||||
|
||||
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
|
||||
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
|
||||
);
|
||||
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
|
||||
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
|
||||
return Result.Success(
|
||||
new AuthResult(
|
||||
accessToken,
|
||||
accessExpiresAt,
|
||||
refreshToken.RawToken,
|
||||
refreshToken.ExpiresAt,
|
||||
dto
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Auth.Register;
|
||||
|
||||
public sealed class RegisterCommandValidator : AbstractValidator<RegisterCommand>
|
||||
{
|
||||
public RegisterCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
|
||||
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>Строит Result/Result<T> failure-ответ через reflection — общий хелпер для generic pipeline behaviors.</summary>
|
||||
internal static class ResultFailureFactory
|
||||
{
|
||||
public static TResponse Create<TResponse>(Error error)
|
||||
where TResponse : Result
|
||||
{
|
||||
if (typeof(TResponse) == typeof(Result))
|
||||
return (TResponse)(object)Result.Failure(error);
|
||||
|
||||
var valueType = typeof(TResponse).GetGenericArguments()[0];
|
||||
var method = typeof(Result)
|
||||
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
|
||||
.MakeGenericMethod(valueType);
|
||||
|
||||
return (TResponse)method.Invoke(null, [error])!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только
|
||||
/// к запросам, реализующим <see cref="ICommand{TResponse}"/> — благодаря generic-ограничению
|
||||
/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery).
|
||||
/// </summary>
|
||||
public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext dbContext)
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : ICommand<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var response = await next();
|
||||
|
||||
if (response is not Result { IsSuccess: false })
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Behaviors;
|
||||
|
||||
public sealed class ValidationBehavior<TRequest, TResponse>(
|
||||
IEnumerable<IValidator<TRequest>> validators
|
||||
) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
where TResponse : Result
|
||||
{
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!validators.Any())
|
||||
return await next();
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
var failures = validators
|
||||
.Select(v => v.Validate(context))
|
||||
.SelectMany(r => r.Errors)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count == 0)
|
||||
return await next();
|
||||
|
||||
var error = Error.Validation(
|
||||
"Validation.Failed",
|
||||
string.Join("; ", failures.Select(f => f.ErrorMessage))
|
||||
);
|
||||
|
||||
return ResultFailureFactory.Create<TResponse>(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Domain.Auth;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public interface IAppDbContext
|
||||
{
|
||||
DbSet<RefreshToken> RefreshTokens { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public interface ICurrentUser
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
string? UserName { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsBlocked);
|
||||
|
||||
public sealed record UserSummaryDto(
|
||||
Guid Id,
|
||||
string UserName,
|
||||
string Role,
|
||||
bool IsBlocked,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
/// <summary>Создаёт пользователя и назначает роль по умолчанию (см. Infrastructure/Identity/RoleNames).</summary>
|
||||
Task<Result<Guid>> CreateUserAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> ChangePasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> ChangeUserNameAsync(
|
||||
Guid userId,
|
||||
string newUserName,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Удаляет аккаунт (самоудаление или удаление админом).</summary>
|
||||
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<PagedList<UserSummaryDto>> ListUsersAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isBlocked,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<UserSummaryDto?> GetUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
|
||||
|
||||
public interface IJwtTokenService
|
||||
{
|
||||
(string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record IssuedRefreshToken(string RawToken, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record RotatedRefreshToken(Guid UserId, string RawToken, DateTimeOffset ExpiresAt);
|
||||
|
||||
public interface IRefreshTokenService
|
||||
{
|
||||
Task<IssuedRefreshToken> IssueAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result<RotatedRefreshToken>> RotateAsync(
|
||||
string rawToken,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task RevokeAsync(string rawToken, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public sealed record RoleDto(Guid Id, string Name, bool IsSystem);
|
||||
|
||||
public interface IRoleService
|
||||
{
|
||||
Task<Result<RoleDto>> CreateRoleAsync(string name, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result<RoleDto>> UpdateRoleAsync(
|
||||
Guid roleId,
|
||||
string name,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<RoleDto>> ListRolesAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace TeleWave.Application.Common.Models;
|
||||
|
||||
public enum ErrorType
|
||||
{
|
||||
Failure,
|
||||
Validation,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
|
||||
{
|
||||
public static readonly Error None = new(string.Empty, string.Empty);
|
||||
|
||||
public static Error Validation(string code, string message) =>
|
||||
new(code, message, ErrorType.Validation);
|
||||
|
||||
public static Error NotFound(string code, string message) =>
|
||||
new(code, message, ErrorType.NotFound);
|
||||
|
||||
public static Error Conflict(string code, string message) =>
|
||||
new(code, message, ErrorType.Conflict);
|
||||
|
||||
public static Error Unauthorized(string code, string message) =>
|
||||
new(code, message, ErrorType.Unauthorized);
|
||||
|
||||
public static Error Forbidden(string code, string message) =>
|
||||
new(code, message, ErrorType.Forbidden);
|
||||
|
||||
public static Error Failure(string code, string message) =>
|
||||
new(code, message, ErrorType.Failure);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace TeleWave.Application.Common.Models;
|
||||
|
||||
public sealed record PagedList<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace TeleWave.Application.Common.Models;
|
||||
|
||||
public class Result
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public Error Error { get; }
|
||||
|
||||
protected Result(bool isSuccess, Error error)
|
||||
{
|
||||
if (isSuccess && error != Error.None)
|
||||
throw new InvalidOperationException("Успешный результат не может содержать ошибку.");
|
||||
if (!isSuccess && error == Error.None)
|
||||
throw new InvalidOperationException("Неуспешный результат обязан содержать ошибку.");
|
||||
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public static Result Success() => new(true, Error.None);
|
||||
|
||||
public static Result Failure(Error error) => new(false, error);
|
||||
|
||||
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
|
||||
|
||||
public static Result<T> Failure<T>(Error error) => new(default, false, error);
|
||||
}
|
||||
|
||||
public class Result<T> : Result
|
||||
{
|
||||
private readonly T? _value;
|
||||
|
||||
internal Result(T? value, bool isSuccess, Error error)
|
||||
: base(isSuccess, error) => _value = value;
|
||||
|
||||
public T Value =>
|
||||
IsSuccess
|
||||
? _value!
|
||||
: throw new InvalidOperationException(
|
||||
"Нельзя получить значение неуспешного результата."
|
||||
);
|
||||
|
||||
public static implicit operator Result<T>(T value) => Success(value);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using LiteCqrs.Behaviors;
|
||||
using LiteCqrs.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TeleWave.Application.Common.Behaviors;
|
||||
|
||||
namespace TeleWave.Application;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
var assembly = typeof(DependencyInjection).Assembly;
|
||||
|
||||
services.AddLiteCqrs(cqrs =>
|
||||
{
|
||||
cqrs.RegisterServicesFromAssembly(assembly);
|
||||
cqrs.Lifetime = ServiceLifetime.Scoped;
|
||||
|
||||
// Порядок важен: Logging (внешний) -> Validation -> UnitOfWork (ближе всего к хендлеру).
|
||||
cqrs.AddOpenBehavior(typeof(LoggingBehavior<,>));
|
||||
cqrs.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||
cqrs.AddOpenBehavior(typeof(UnitOfWorkBehavior<,>));
|
||||
});
|
||||
|
||||
RegisterClosedGeneric(services, assembly, typeof(IValidator<>));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterClosedGeneric(IServiceCollection services, Assembly assembly, Type openInterface)
|
||||
{
|
||||
var implementations = assembly.GetTypes()
|
||||
.Where(t => t is { IsClass: true, IsAbstract: false })
|
||||
.SelectMany(t => t.GetInterfaces()
|
||||
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == openInterface)
|
||||
.Select(i => (Service: i, Implementation: t)));
|
||||
|
||||
foreach (var (service, implementation) in implementations)
|
||||
services.AddScoped(service, implementation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TeleWave.Domain\TeleWave.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" />
|
||||
<PackageReference Include="LiteCqrs.Net" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user