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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"SigningKey": ""
|
||||
},
|
||||
"AuthHex": {
|
||||
"BaseUrl": "http://localhost:6703"
|
||||
"BaseUrl": "http://localhost:6703",
|
||||
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
|
||||
},
|
||||
"GeneralLedgerService": {
|
||||
"BaseUrl": "http://localhost:6704/api/v1/",
|
||||
|
||||
Reference in New Issue
Block a user