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:
Dhananjaya99
2026-10-15 10:54:28 +05:30
parent 5ec3edc0c6
commit 61330f3927
26 changed files with 817 additions and 141 deletions
@@ -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);
+24 -1
View File
@@ -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);