mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Add self-service account management commands (delete, update username/email/password/profile)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
|
||||
/// <summary>Permanently deletes the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record DeleteAccountCommand(Guid UserAccountId) : IRequest;
|
||||
@@ -0,0 +1,33 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
|
||||
/// <summary>Handles <see cref="DeleteAccountCommand" /> by deleting the user via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class DeleteAccountHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<DeleteAccountCommand>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when the account cannot be deleted because other records (posts, comments, photos,
|
||||
/// follows) still reference it.
|
||||
/// </exception>
|
||||
public async Task Handle(DeleteAccountCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.DeleteAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "AccountHasDependentRecords"))
|
||||
throw new ConflictException(
|
||||
"Account cannot be deleted while it still has associated posts, comments, photos, or follows."
|
||||
);
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||
/// request body of <c>POST /api/auth/refresh</c>.
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the
|
||||
/// <c>X-Refresh-Token</c> header of <c>POST /api/auth/refresh</c>.
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
@@ -28,14 +29,14 @@ public class RegisterUserHandler(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user = new()
|
||||
ApplicationUser user = new UserAccount
|
||||
{
|
||||
UserName = request.Username,
|
||||
Username = request.Username,
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
};
|
||||
}.ToApplicationUser();
|
||||
|
||||
IdentityResult result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Changes the email address of the given user account. The account's <c>EmailConfirmed</c> status
|
||||
/// is reset to unconfirmed as a result.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateEmailCommand(Guid UserAccountId, string NewEmail) : IRequest<UpdateEmailPayload>;
|
||||
@@ -0,0 +1,39 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateEmailCommand" /> via <see cref="UserManager{TUser}" />.
|
||||
/// <see cref="UserManager{TUser}.SetEmailAsync" /> also resets <c>EmailConfirmed</c> to
|
||||
/// <see langword="false" />, so the user must re-confirm the new address (see
|
||||
/// <c>Features.Auth.Commands.ResendConfirmationEmail</c>).
|
||||
/// </summary>
|
||||
public class UpdateEmailHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateEmailCommand, UpdateEmailPayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the requested email address is already in use.</exception>
|
||||
public async Task<UpdateEmailPayload> Handle(
|
||||
UpdateEmailCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.SetEmailAsync(user, request.NewEmail);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "DuplicateEmail"))
|
||||
throw new ConflictException("Email address already exists");
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return new UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateEmailCommand" />, mirroring the email rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateEmailValidator : AbstractValidator<UpdateEmailCommand>
|
||||
{
|
||||
public UpdateEmailValidator()
|
||||
{
|
||||
RuleFor(x => x.NewEmail)
|
||||
.NotEmpty()
|
||||
.WithMessage("Email is required")
|
||||
.EmailAddress()
|
||||
.WithMessage("Invalid email format")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Email cannot exceed 128 characters");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>Changes the password of the given user account, verifying the current password first.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdatePasswordCommand(Guid UserAccountId, string CurrentPassword, string NewPassword)
|
||||
: IRequest<UpdatePasswordPayload>;
|
||||
@@ -0,0 +1,42 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdatePasswordCommand" /> via <see cref="UserManager{TUser}.ChangePasswordAsync" />,
|
||||
/// which verifies the current password before hashing and persisting the new one.
|
||||
/// </summary>
|
||||
public class UpdatePasswordHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdatePasswordCommand, UpdatePasswordPayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="UnauthorizedException">Thrown when the current password does not match.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
|
||||
public async Task<UpdatePasswordPayload> Handle(
|
||||
UpdatePasswordCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.ChangePasswordAsync(
|
||||
user,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword
|
||||
);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "PasswordMismatch"))
|
||||
throw new UnauthorizedException("Current password is incorrect");
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return new UpdatePasswordPayload(user.Id, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>Validates <see cref="UpdatePasswordCommand" />, mirroring the password rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdatePasswordValidator : AbstractValidator<UpdatePasswordCommand>
|
||||
{
|
||||
public UpdatePasswordValidator()
|
||||
{
|
||||
RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Current password is required");
|
||||
|
||||
RuleFor(x => x.NewPassword)
|
||||
.NotEmpty()
|
||||
.WithMessage("New password is required")
|
||||
.MinimumLength(8)
|
||||
.WithMessage("Password must be at least 8 characters")
|
||||
.Matches("[A-Z]")
|
||||
.WithMessage("Password must contain at least one uppercase letter")
|
||||
.Matches("[a-z]")
|
||||
.WithMessage("Password must contain at least one lowercase letter")
|
||||
.Matches("[0-9]")
|
||||
.WithMessage("Password must contain at least one number")
|
||||
.Matches("[^a-zA-Z0-9]")
|
||||
.WithMessage("Password must contain at least one special character")
|
||||
.NotEqual(x => x.CurrentPassword)
|
||||
.WithMessage("New password must be different from the current password");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the non-credential profile fields (first name, last name, date of birth) of the given
|
||||
/// user account.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateProfileCommand(
|
||||
Guid UserAccountId,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime DateOfBirth
|
||||
) : IRequest<UpdateProfilePayload>;
|
||||
@@ -0,0 +1,38 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateProfileCommand" /> via <see cref="UserManager{TUser}" />. First/last name
|
||||
/// and date of birth aren't tracked by any Identity-specific store interface, so the mutated
|
||||
/// <see cref="ApplicationUser" /> is saved directly through <see cref="UserManager{TUser}.UpdateAsync" />.
|
||||
/// </summary>
|
||||
public class UpdateProfileHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateProfileCommand, UpdateProfilePayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
|
||||
public async Task<UpdateProfilePayload> Handle(
|
||||
UpdateProfileCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
user.FirstName = request.FirstName;
|
||||
user.LastName = request.LastName;
|
||||
user.DateOfBirth = request.DateOfBirth;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
|
||||
return new UpdateProfilePayload(user.Id, user.FirstName, user.LastName, user.DateOfBirth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateProfileCommand" />, mirroring the profile rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateProfileValidator : AbstractValidator<UpdateProfileCommand>
|
||||
{
|
||||
public UpdateProfileValidator()
|
||||
{
|
||||
RuleFor(x => x.FirstName)
|
||||
.NotEmpty()
|
||||
.WithMessage("First name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("First name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.LastName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Last name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Last name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.DateOfBirth)
|
||||
.NotEmpty()
|
||||
.WithMessage("Date of birth is required")
|
||||
.LessThan(DateTime.Today.AddYears(-19))
|
||||
.WithMessage("You must be at least 19 years old");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Changes the username of the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateUsernameCommand(Guid UserAccountId, string NewUsername)
|
||||
: IRequest<UpdateUsernamePayload>;
|
||||
@@ -0,0 +1,34 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Handles <see cref="UpdateUsernameCommand" /> via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class UpdateUsernameHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateUsernameCommand, UpdateUsernamePayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the requested username is already taken.</exception>
|
||||
public async Task<UpdateUsernamePayload> Handle(
|
||||
UpdateUsernameCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.SetUserNameAsync(user, request.NewUsername);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "DuplicateUserName"))
|
||||
throw new ConflictException("Username already exists");
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return new UpdateUsernamePayload(user.Id, user.UserName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateUsernameCommand" />, mirroring the username rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateUsernameValidator : AbstractValidator<UpdateUsernameCommand>
|
||||
{
|
||||
public UpdateUsernameValidator()
|
||||
{
|
||||
RuleFor(x => x.NewUsername)
|
||||
.NotEmpty()
|
||||
.WithMessage("Username is required")
|
||||
.Length(3, 64)
|
||||
.WithMessage("Username must be between 3 and 64 characters")
|
||||
.Matches("^[a-zA-Z0-9._-]+$")
|
||||
.WithMessage(
|
||||
"Username can only contain letters, numbers, dots, underscores, and hyphens"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user