feat: implement user role status toggle and reset password functionality
- Added toggle functionality for user roles (activate/deactivate) in roles and users pages. - Introduced a reset password page that validates and updates user passwords via a reset link. - Enhanced user detail page with edit functionality for user information (display name, email, mobile number). - Implemented sending reset link feature for users, with appropriate error handling and success notifications. - Updated API endpoints for user management to support new functionalities. - Improved UI components for better user experience during status updates and password changes.
This commit is contained in:
@@ -118,6 +118,12 @@ public sealed class BranchService : IBranchService
|
||||
var branch = await _branches.GetByIdAsync(branchId, ct)
|
||||
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
||||
|
||||
if (status == EntityStatus.Inactive && branch.Code == Branch.DefaultCode)
|
||||
throw new DomainException(
|
||||
ErrorCodes.DefaultBranchProtected,
|
||||
$"'{branch.Name}' is the deployment's default branch and cannot be deactivated.",
|
||||
409);
|
||||
|
||||
branch.Status = status;
|
||||
branch.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
@@ -128,6 +134,12 @@ public sealed class BranchService : IBranchService
|
||||
var branch = await _branches.GetByIdAsync(branchId, ct)
|
||||
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
||||
|
||||
if (branch.Code == Branch.DefaultCode)
|
||||
throw new DomainException(
|
||||
ErrorCodes.DefaultBranchProtected,
|
||||
$"'{branch.Name}' is the deployment's default branch and cannot be deleted.",
|
||||
409);
|
||||
|
||||
_branches.Remove(branch);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -17,16 +17,27 @@ public interface IUserManagementService
|
||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Edits DisplayName/Email (and MobileNumber upstream) on both the local shadow row and,
|
||||
/// when linked, the AuthHex identity itself (via the admin-only AdminUpdateUser function).
|
||||
/// </summary>
|
||||
Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Flips the local shadow row's status (Active/Inactive) — this is what
|
||||
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
|
||||
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
|
||||
/// is self-service, acting on whoever's bearer token is passed), so the
|
||||
/// credential itself stays live upstream; this only revokes the account's
|
||||
/// standing inside ERPCore.
|
||||
/// BranchAccessService and the rest of ERPCore gate on — and, when the user has a
|
||||
/// linked AuthHex identity, also flips it there via the admin-only AdminSetUserStatus
|
||||
/// function (deactivating additionally invalidates their live AuthHex sessions), so a
|
||||
/// user shown Inactive here can no longer log in at all, not just inside ERPCore.
|
||||
/// </summary>
|
||||
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Emails the user a time-limited (15 min) password-reset link via AuthHex's existing
|
||||
/// self-service forgot-password flow, triggered on their behalf by an admin.
|
||||
/// </summary>
|
||||
Task<SendResetLinkResponse> SendResetLinkAsync(int userId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
|
||||
private readonly IRepository<RolePermission> _rolePermissions;
|
||||
private readonly IRepository<Permission> _permissions;
|
||||
private readonly IRepository<Employee> _employees;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
@@ -29,12 +30,14 @@ public sealed class RoleService : IRoleService
|
||||
|
||||
public RoleService(
|
||||
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
||||
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex,
|
||||
IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
{
|
||||
_roles = roles;
|
||||
_rolePermissions = rolePermissions;
|
||||
_permissions = permissions;
|
||||
_employees = employees;
|
||||
_users = users;
|
||||
_currentUser = currentUser;
|
||||
_authHex = authHex;
|
||||
_branchConfig = branchConfig;
|
||||
@@ -128,6 +131,9 @@ public sealed class RoleService : IRoleService
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
if (status == EntityStatus.Inactive)
|
||||
await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct);
|
||||
|
||||
role.Status = status;
|
||||
role.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
@@ -138,6 +144,8 @@ public sealed class RoleService : IRoleService
|
||||
var role = await _roles.GetByIdAsync(roleId, ct)
|
||||
?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct);
|
||||
|
||||
try
|
||||
{
|
||||
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
|
||||
@@ -151,6 +159,21 @@ public sealed class RoleService : IRoleService
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A role still assigned to at least one local (ERPCore shadow) user cannot be
|
||||
/// deactivated or deleted — that user would be left pointing at a role they can no
|
||||
/// longer effectively hold. Reassign them to a different role first.
|
||||
/// </summary>
|
||||
private async Task EnsureNotAssignedToAnyUserAsync(int roleId, string action, CancellationToken ct)
|
||||
{
|
||||
var assignedUserCount = await _users.Query().AsNoTracking().CountAsync(u => u.RoleId == roleId, ct);
|
||||
if (assignedUserCount > 0)
|
||||
throw new DomainException(
|
||||
ErrorCodes.RoleInUse,
|
||||
$"This role is assigned to {assignedUserCount} user(s) and cannot be {action}. Reassign them to a different role first.",
|
||||
409);
|
||||
}
|
||||
|
||||
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
|
||||
{
|
||||
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
|
||||
|
||||
@@ -173,17 +173,65 @@ public sealed class UserManagementService : IUserManagementService
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
|
||||
?? throw new NotFoundException($"User {userId} was not found.");
|
||||
|
||||
var displayName = request.DisplayName.Trim();
|
||||
var email = request.Email.Trim();
|
||||
|
||||
// Source of truth first — if AuthHex rejects this (e.g. email already taken by
|
||||
// another identity), the local row must not drift out of sync with it.
|
||||
if (user.AuthUserId is Guid authUserId)
|
||||
{
|
||||
await _authHex.AdminUpdateUserAsync(authUserId, new AdminUpdateUserRequest
|
||||
{
|
||||
FullName = displayName,
|
||||
Email = email,
|
||||
MobileNumber = request.MobileNumber
|
||||
}, ct);
|
||||
}
|
||||
|
||||
user.DisplayName = displayName;
|
||||
user.Email = email;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
|
||||
?? throw new NotFoundException($"User {userId} was not found.");
|
||||
|
||||
// The local flag alone is cosmetic — without this, a user shown "Inactive" here
|
||||
// could still log in directly against AuthHex.
|
||||
if (user.AuthUserId is Guid authUserId)
|
||||
await _authHex.AdminSetUserStatusAsync(authUserId, status == EntityStatus.Active, ct);
|
||||
|
||||
user.Status = status;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<SendResetLinkResponse> SendResetLinkAsync(int userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.GetByIdAsync(userId, ct)
|
||||
?? throw new NotFoundException($"User {userId} was not found.");
|
||||
if (string.IsNullOrWhiteSpace(user.Email))
|
||||
throw new DomainException(ErrorCodes.Validation, "This user has no email address on file.", 422);
|
||||
|
||||
var result = await _authHex.ForgotPasswordAsync(new ForgotPasswordRequest
|
||||
{
|
||||
Identifier = user.Email,
|
||||
UseResetLink = true
|
||||
}, ct);
|
||||
|
||||
return new SendResetLinkResponse(result.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user