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
@@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
[HttpPatch("{**path}")]
public Task<IActionResult> Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct);
[HttpDelete("{**path}")]
public Task<IActionResult> Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
+15 -1
View File
@@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
/// <summary>Edits DisplayName/Email/MobileNumber on both backends — see <see cref="IUserManagementService.UpdateAsync"/>.</summary>
[HttpPut("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct)
=> Ok(await _users.UpdateAsync(userId, request, ct));
/// <summary>Revokes/restores standing in both ERPCore and AuthHex — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Emails the user a 15-minute password-reset link — see <see cref="IUserManagementService.SendResetLinkAsync"/>.</summary>
[HttpPost("{userId:int}/send-reset-link")]
[ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SendResetLinkResponse>> SendResetLink(int userId, CancellationToken ct)
=> Ok(await _users.SendResetLinkAsync(userId, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
@@ -44,6 +44,15 @@ public sealed class DeletedAuthHexUser
public string? Email { get; set; }
}
/// <summary>Admin-only profile edit — any field left null is left unchanged upstream.</summary>
public sealed class AdminUpdateUserRequest
{
public string? FullName { get; set; }
public string? UserName { get; set; }
public string? Email { get; set; }
public string? MobileNumber { get; set; }
}
public sealed class RegisterRequest
{
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
+15
View File
@@ -67,5 +67,20 @@ public sealed class UpdateUserStatusRequest
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>
/// Edits an existing user's identity fields in both backends: the local shadow row
/// (DisplayName/Email) and, when the user has a linked AuthHex identity, the AuthHex
/// account itself via the admin-only AdminUpdateUser function. A user created before
/// AuthHex linking (AuthUserId null) only updates locally.
/// </summary>
public sealed class UpdateManagedUserRequest
{
[Required, StringLength(200)] public string DisplayName { get; set; } = string.Empty;
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
public string? MobileNumber { get; set; }
}
public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
@@ -93,16 +93,35 @@ public sealed class AuthHexClient : IAuthHexClient
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
return CallAsync<DeleteAuthHexUserResult>(
=> CallAsync<DeleteAuthHexUserResult>(
"user", "deleteUser",
new { email, mobileNumber, nic, userName = username },
null, ct,
extraHeaders: new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey });
extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminUpdateUser",
new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber },
null, ct,
extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminSetUserStatus",
new { userId, isActive },
null, ct,
extraHeaders: AdminHeaders());
/// <summary>Shared guard for every admin-only AuthHex function — throws up front rather
/// than leaving a misconfigured deployment to fail opaquely on the network call.</summary>
private Dictionary<string, string> AdminHeaders()
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call this admin function.", 500);
return new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey };
}
// ---- RecoveryManager ------------------------------------------------
@@ -38,6 +38,15 @@ public interface IAuthHexClient
/// </summary>
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
/// <summary>Admin-only: updates another user's identity fields (not self-service —
/// <see cref="UpdateUserAsync"/> only ever touches the caller's own account).
/// Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct);
/// <summary>Admin-only: activates/deactivates another user's AuthHex identity and, when
/// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct);
// RecoveryManager (POST /api/recovery)
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
@@ -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);
@@ -56,6 +56,7 @@ public static class ErrorCodes
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
public const string DefaultBranchProtected = "DEFAULT_BRANCH_PROTECTED";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE";
+2 -1
View File
@@ -9,7 +9,8 @@
"DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
"BaseUrl": "http://localhost:5011",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
},
"GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/"
+2 -1
View File
@@ -3,7 +3,8 @@
"SigningKey": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:6703"
"BaseUrl": "http://localhost:6703",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
},
"GeneralLedgerService": {
"BaseUrl": "http://localhost:6704/api/v1/",