From 15832d1ae3bb9dcc93d419fce015a04850e0b073 Mon Sep 17 00:00:00 2001 From: Dhananjaya99 <152056742+ashanruu@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:57:07 +0530 Subject: [PATCH] Decouple UserType, add Role entity and management Removed all UserType logic and references, replacing them with a new Role model and management system. Introduced RoleManager service, repository, and API endpoint for role CRUD operations. Updated user flows and JWT claims to use RoleCode. Removed admin-only user CRUD endpoints. Updated DI, documentation, and error handling for consistency. Appsettings and DTOs now reflect the new role-based approach. --- API_DOCUMENTATION.md | 5 - AppDBContext.cs | 16 +- Controllers/APIController.cs | 11 +- Interfaces/IRoleManageRepository.cs | 13 + Interfaces/IUserManageRepository.cs | 2 +- Models/Role.cs | 15 ++ Models/User.cs | 9 +- Models/UserType.cs | 13 - Program.cs | 3 + Repos/RoleManageRepository.cs | 43 ++++ Repos/UserManageRepository.cs | 4 +- .../AltOptionManagerService.cs | 6 +- Services/FunctionHandler/RoleManager.cs | 72 ++++++ Services/FunctionHandler/UserManager.cs | 17 +- .../RecoveryManager/RecoveryManagerService.cs | 57 +++-- Services/RoleManager/RoleManagerService.cs | 151 +++++++++++ Services/UserManager/UserManagerService.cs | 234 +++--------------- Utility/JwtTokenHelper.cs | 14 +- appsettings.json | 5 +- 19 files changed, 393 insertions(+), 297 deletions(-) create mode 100644 Interfaces/IRoleManageRepository.cs create mode 100644 Models/Role.cs delete mode 100644 Models/UserType.cs create mode 100644 Repos/RoleManageRepository.cs create mode 100644 Services/FunctionHandler/RoleManager.cs create mode 100644 Services/RoleManager/RoleManagerService.cs diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 6eb60d6..ac9c594 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -3,11 +3,6 @@ Base route: `api` All endpoints are `[AllowAnonymous]` at the HTTP layer, but individual functions may require a Bearer JWT (read via `HttpContext` claims) — noted per function below. -> **Roles were removed (2026-10).** AuthHex is authentication only: it has no roles, `/api/role` no longer exists, `registerUser` -> no longer needs (and ignores) `roleId`, user payloads carry no `roleId`/`role`, and the JWT has no `RoleId`/`RoleCode` -> claims. The ERP owns roles and permissions (users.RoleId in ERPCore). The role fields and the **/api/role** section -> below describe the old behaviour and are kept only for reference. Migration steps: `Docs/migrations/2026-10-remove-roles.sql`. - ## Common envelope **Request** (`ApiRequest`) diff --git a/AppDBContext.cs b/AppDBContext.cs index 3ff4cf6..c2d962c 100644 --- a/AppDBContext.cs +++ b/AppDBContext.cs @@ -8,8 +8,8 @@ namespace AuthHex { public AppDBContext(DbContextOptions options) : base(options) { } public DbSet Users => Set(); - public DbSet UserType => Set(); - public DbSet UserSessions => Set(); + public DbSet Roles => Set(); +public DbSet UserSessions => Set(); public DbSet Token => Set(); @@ -68,19 +68,13 @@ namespace AuthHex .HasForeignKey(up => up.UserId) .OnDelete(DeleteBehavior.Cascade); - // Configure UserType primary key - modelBuilder.Entity() - .HasKey(ut => ut.UserTypeId); + // Configure Role primary key + modelBuilder.Entity() + .HasKey(r => r.RoleId); // Configure User primary key and relationships modelBuilder.Entity() .HasKey(u => u.Id); - - modelBuilder.Entity() - .HasOne(u => u.UserType) - .WithMany() - .HasForeignKey(u => u.UserTypeId) - .OnDelete(DeleteBehavior.Restrict); } } diff --git a/Controllers/APIController.cs b/Controllers/APIController.cs index cbe8508..5567d97 100644 --- a/Controllers/APIController.cs +++ b/Controllers/APIController.cs @@ -10,11 +10,12 @@ namespace AuthHex.Controllers [ApiController] [Route("api")] [EnableCors("AllowAll")] - public class APIController(UserManager userManager, RecoveryManager recoveryManager, AltOptionManager altOptionManager) : ControllerBase + public class APIController(UserManager userManager, RecoveryManager recoveryManager, AltOptionManager altOptionManager, RoleManager roleManager) : ControllerBase { private readonly UserManager _userManager = userManager; private readonly RecoveryManager _recoveryManager = recoveryManager; private readonly AltOptionManager _altOptionManager = altOptionManager; + private readonly RoleManager _roleManager = roleManager; [HttpGet("status")] [AllowAnonymous] @@ -75,5 +76,13 @@ namespace AuthHex.Controllers return StatusCode(response.StatusCode, response); } + [HttpPost("role")] + [AllowAnonymous] + public async Task RoleExecute([FromBody] ApiRequest request) + { + var response = await _roleManager.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + } } diff --git a/Interfaces/IRoleManageRepository.cs b/Interfaces/IRoleManageRepository.cs new file mode 100644 index 0000000..b47daf1 --- /dev/null +++ b/Interfaces/IRoleManageRepository.cs @@ -0,0 +1,13 @@ +using AuthHex.Models; + +namespace AuthHex.Interfaces +{ + public interface IRoleManageRepository + { + Task AddRoleAsync(Role role, CancellationToken ct = default); + Task GetRoleByIdAsync(Guid roleId, CancellationToken ct = default); + Task> ListRolesAsync(CancellationToken ct = default); + //Task RoleInUseAsync(Guid roleId, CancellationToken ct = default); + Task DeleteRoleAsync(Role role, CancellationToken ct = default); + } +} diff --git a/Interfaces/IUserManageRepository.cs b/Interfaces/IUserManageRepository.cs index f5f2dde..a28c7df 100644 --- a/Interfaces/IUserManageRepository.cs +++ b/Interfaces/IUserManageRepository.cs @@ -7,7 +7,7 @@ namespace AuthHex.Interfaces Task AddUserAsync(User user, CancellationToken ct = default); Task AddTokenAsync(Token token, CancellationToken ct = default); Task> GetUserByIdentifiers(string? email, string? mobileNumber, string? nic, string? username, string? Optional1); - Task GetUserByIdentifierAndType(string identifier, Guid? userTypeId); + Task GetUserByIdentifierAndType(string identifier); Task GetUserByIdAsync(Guid userId, CancellationToken ct = default); Task UpdateUserAsync(User user, CancellationToken ct = default); Task> GetUserSessionsAsync(Guid userId, CancellationToken ct = default); diff --git a/Models/Role.cs b/Models/Role.cs new file mode 100644 index 0000000..e834360 --- /dev/null +++ b/Models/Role.cs @@ -0,0 +1,15 @@ +namespace AuthHex.Models +{ + public class Role + { + public Guid RoleId { get; set; } + + public string Code { get; set; } = string.Empty; // ADMIN, USER, STAFF + public string?Name { get; set; } = string.Empty; + + public bool? IsSystemRole { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + } +} diff --git a/Models/User.cs b/Models/User.cs index bbbe765..49d3775 100644 --- a/Models/User.cs +++ b/Models/User.cs @@ -30,13 +30,8 @@ namespace AuthHex.Models public bool IsMfaEnabled { get; set; } public UserMfa? UserMfa { get; set; } - // Roles are not an authentication concern: the ERP owns a person's role and permissions (users.RoleId in ERPCore). - // AuthHex only says who the person is. (The old Users.RoleId column is left in the table, nullable and unmapped, - // until it is dropped - see Docs/migrations/2026-10-remove-roles.sql.) - public Guid UserTypeId { get; set; } - public UserType? UserType { get; set; } - - + //User Role and Type + public string? RoleCode { get; set; } public ICollection? Sessions { get; set; } public ICollection? Providers { get; set; } } diff --git a/Models/UserType.cs b/Models/UserType.cs deleted file mode 100644 index f7879a7..0000000 --- a/Models/UserType.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace AuthHex.Models -{ - public class UserType - { - public Guid UserTypeId { get; set; } - - public string Code { get; set; } = string.Empty; // INDIVIDUAL, MERCHANT, AGENT - public string? Description { get; set; } = string.Empty; - - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - } -} diff --git a/Program.cs b/Program.cs index 73c076a..174b969 100644 --- a/Program.cs +++ b/Program.cs @@ -57,15 +57,18 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var configuredOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); var allowedOrigins = configuredOrigins diff --git a/Repos/RoleManageRepository.cs b/Repos/RoleManageRepository.cs new file mode 100644 index 0000000..cfa4ace --- /dev/null +++ b/Repos/RoleManageRepository.cs @@ -0,0 +1,43 @@ +using AuthHex.Interfaces; +using AuthHex.Models; +using Microsoft.EntityFrameworkCore; + +namespace AuthHex.Repos +{ + public class RoleManageRepository : IRoleManageRepository + { + private readonly AppDBContext _dbContext; + + public RoleManageRepository(AppDBContext dbContext) + { + _dbContext = dbContext; + } + + public Task AddRoleAsync(Role role, CancellationToken ct = default) + { + _dbContext.Roles.Add(role); + return Task.FromResult(role); + } + + public async Task GetRoleByIdAsync(Guid roleId, CancellationToken ct = default) + { + return await _dbContext.Roles.FirstOrDefaultAsync(r => r.RoleId == roleId, ct); + } + + public async Task> ListRolesAsync(CancellationToken ct = default) + { + return await _dbContext.Roles.OrderBy(r => r.Code).ToListAsync(ct); + } + + //public async Task RoleInUseAsync(Guid roleId, CancellationToken ct = default) + //{ + // return await _dbContext.Users.AnyAsync(u => u.RoleId == roleId, ct); + //} + + public Task DeleteRoleAsync(Role role, CancellationToken ct = default) + { + _dbContext.Roles.Remove(role); + return Task.CompletedTask; + } + } +} diff --git a/Repos/UserManageRepository.cs b/Repos/UserManageRepository.cs index 1d1db84..9533869 100644 --- a/Repos/UserManageRepository.cs +++ b/Repos/UserManageRepository.cs @@ -43,9 +43,8 @@ namespace AuthHex.Repos } - public async Task GetUserByIdentifierAndType(string identifier, Guid? userTypeId) + public async Task GetUserByIdentifierAndType(string identifier) { - User? user = await _dbContext.Users .FirstOrDefaultAsync(u => u.Email == identifier || @@ -70,7 +69,6 @@ namespace AuthHex.Repos public async Task GetUserByIdAsync(Guid userId, CancellationToken ct = default) { return await _dbContext.Users - .Include(u => u.UserType) .FirstOrDefaultAsync(u => u.Id == userId, ct); } diff --git a/Services/AltOptionManager/AltOptionManagerService.cs b/Services/AltOptionManager/AltOptionManagerService.cs index d87e37c..668732d 100644 --- a/Services/AltOptionManager/AltOptionManagerService.cs +++ b/Services/AltOptionManager/AltOptionManagerService.cs @@ -160,7 +160,7 @@ public class AltOptionManagerService //exisiting one - var user = await _userRepository.GetUserByIdentifierAndType(identifier, null); + var user = await _userRepository.GetUserByIdentifierAndType(identifier); if (user == null) throw new KeyNotFoundException("No user found with the provided identifier."); @@ -293,7 +293,7 @@ public class AltOptionManagerService User? user = null; if (!string.IsNullOrWhiteSpace(identifier)) { - user = await _userRepository.GetUserByIdentifierAndType(identifier, null); + user = await _userRepository.GetUserByIdentifierAndType(identifier); } if (user is null && userId.HasValue) @@ -377,7 +377,7 @@ public class AltOptionManagerService user.EmailVerified, user.MobileNumberVerified, user.IsMfaEnabled, - UserTypeId = user.UserTypeId + RoleId = user.RoleCode } } }; diff --git a/Services/FunctionHandler/RoleManager.cs b/Services/FunctionHandler/RoleManager.cs new file mode 100644 index 0000000..3d6e071 --- /dev/null +++ b/Services/FunctionHandler/RoleManager.cs @@ -0,0 +1,72 @@ +using AuthHex.Models.IOs; +using AuthHex.Services.RoleManager; + +namespace AuthHex.Services.FunctionHandler +{ + public class RoleManager + { + public readonly Dictionary>> _functionHandler; + + public RoleManager(RoleManagerService roleManagerService) + { + _functionHandler = new() + { + { "createRole", roleManagerService.CreateRole }, + { "getRole", roleManagerService.GetRole }, + { "listRoles", async (payload, context) => await roleManagerService.ListRoles(context) }, + { "updateRole", roleManagerService.UpdateRole }, + { "deleteRole", roleManagerService.DeleteRole } + }; + } + + public async Task Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (ArgumentException ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = ex.Message, + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/UserManager.cs b/Services/FunctionHandler/UserManager.cs index 9d1d46f..4c95783 100644 --- a/Services/FunctionHandler/UserManager.cs +++ b/Services/FunctionHandler/UserManager.cs @@ -18,19 +18,13 @@ namespace AuthHex.Services.FunctionHandler { "refreshToken", UserManagerService.RefreshToken }, { "getUserDetails", UserManagerService.GetUserDetails }, { "getUserSessions", async (payload, context) => await UserManagerService.GetUserSessions(context) }, - { "listUserTypes", async (payload, context) => await UserManagerService.ListUserTypes(context) }, - { "ChangeUserStatus", UserManagerService.ChangeUserStatus}, +{ "ChangeUserStatus", UserManagerService.ChangeUserStatus}, {"LockUserAccount" , UserManagerService.LockUserAccount }, {"ChangeUserPassword" , UserManagerService.ChangeUserPassword }, {"VerifyPassword" , UserManagerService.VerifyPassword }, {"LogoutUser" , UserManagerService.LogoutUser}, {"UpdateUser", UserManagerService.UpdateUser}, {"VerifyOtpForLogin",UserManagerService.VerifyOtpForLogin}, - - // Admin-only — require X-Admin-Api-Key, act on an arbitrary target user. - {"AdminUpdateUser", UserManagerService.AdminUpdateUser}, - {"AdminSetUserStatus", UserManagerService.AdminSetUserStatus}, - {"deleteUser", UserManagerService.DeleteUser}, // 2FA Management Functions { "initiateTwoFASetup", async (payload, context) => await UserManagerService.InitiateTwoFASetup(context) }, @@ -62,15 +56,6 @@ namespace AuthHex.Services.FunctionHandler Data = results }; } - catch (UnauthorizedAccessException ex) - { - return new ApiResponse - { - StatusCode = 401, - Message = ex.Message, - Data = null - }; - } catch (KeyNotFoundException ex) { return new ApiResponse diff --git a/Services/RecoveryManager/RecoveryManagerService.cs b/Services/RecoveryManager/RecoveryManagerService.cs index 52ee4d4..eb9bed9 100644 --- a/Services/RecoveryManager/RecoveryManagerService.cs +++ b/Services/RecoveryManager/RecoveryManagerService.cs @@ -53,7 +53,7 @@ public class RecoveryManagerService if (string.IsNullOrEmpty(identifier)) throw new ArgumentException("Identifier is required."); - var user = await _userRepository.GetUserByIdentifierAndType(identifier, null); + var user = await _userRepository.GetUserByIdentifierAndType(identifier); if (user == null) throw new KeyNotFoundException("No user found with the provided identifier."); @@ -119,9 +119,14 @@ public class RecoveryManagerService return new { - ReferenceNumber = recovery.RecoveryReferenceNum, - ExpiresAt = recovery.ExpirationTime, - RecoveryType = "ResetLink" + Success = true, + Message = "Password reset link sent to your email.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = "ResetLink" + } }; } else @@ -171,9 +176,14 @@ public class RecoveryManagerService return new { - ReferenceNumber = recovery.RecoveryReferenceNum, - ExpiresAt = recovery.ExpirationTime, - RecoveryType = "OTP" + Success = true, + Message = "OTP sent successfully.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + ExpiresAt = recovery.ExpirationTime, + RecoveryType = "OTP" + } }; } } @@ -227,9 +237,14 @@ public class RecoveryManagerService return new { - ReferenceNumber = recovery.RecoveryReferenceNum, - UserId = recovery.UserId, - Verified = true + Success = true, + Message = "OTP verified successfully. You can now reset your password.", + Data = new + { + ReferenceNumber = recovery.RecoveryReferenceNum, + UserId = recovery.UserId, + Verified = true + } }; } catch @@ -328,9 +343,14 @@ public class RecoveryManagerService return new { - UserId = user.Id, - Email = user.Email, - ResetAt = DateTime.UtcNow + Success = true, + Message = "Password reset successful. Please login with your new password.", + Data = new + { + UserId = user.Id, + Email = user.Email, + ResetAt = DateTime.UtcNow + } }; } catch @@ -421,9 +441,14 @@ public class RecoveryManagerService return new { - UserId = user.Id, - Email = user.Email, - ResetAt = DateTime.UtcNow + Success = true, + Message = "Password reset successful. Please login with your new password.", + Data = new + { + UserId = user.Id, + Email = user.Email, + ResetAt = DateTime.UtcNow + } }; } catch diff --git a/Services/RoleManager/RoleManagerService.cs b/Services/RoleManager/RoleManagerService.cs new file mode 100644 index 0000000..e123278 --- /dev/null +++ b/Services/RoleManager/RoleManagerService.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using AuthHex.Infra.UoW; +using AuthHex.Interfaces; +using AuthHex.Models; +using Core_Archi.Helpers; +using Microsoft.AspNetCore.Http; + +namespace AuthHex.Services.RoleManager; + +public class RoleManagerService +{ + private readonly IRoleManageRepository _repository; + private readonly IUnitOfWork _uow; + + public RoleManagerService(IRoleManageRepository repository, IUnitOfWork uow) + { + _repository = repository; + _uow = uow; + } + + public async Task CreateRole(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var code = data.ContainsKey("code") ? data["code"].GetString() : null; + var name = data.ContainsKey("name") ? data["name"].GetString() : null; + var isSystemRole = data.ContainsKey("isSystemRole") + && data["isSystemRole"].ValueKind != JsonValueKind.Null + && data["isSystemRole"].GetBoolean(); + + if (string.IsNullOrWhiteSpace(code)) + throw new ArgumentException("code is required"); + + var role = new Role + { + RoleId = Guid.NewGuid(), + Code = code, + Name = string.IsNullOrWhiteSpace(name) ? code : name, + IsSystemRole = isSystemRole, + CreatedAt = DateTime.UtcNow + }; + + await _repository.AddRoleAsync(role); + await _uow.CommitAsync(); + + return ToDto(role); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetRole(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data.ContainsKey("roleId") || !Guid.TryParse(data["roleId"].GetString(), out var roleId)) + throw new ArgumentException("Invalid roleId"); + + var role = await _repository.GetRoleByIdAsync(roleId) + ?? throw new KeyNotFoundException("Role not found"); + + return ToDto(role); + } + + public async Task ListRoles(HttpContext httpContext) + { + var roles = await _repository.ListRolesAsync(); + return roles.Select(ToDto).ToList(); + } + + public async Task UpdateRole(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data.ContainsKey("roleId") || !Guid.TryParse(data["roleId"].GetString(), out var roleId)) + throw new ArgumentException("Invalid roleId"); + + var role = await _repository.GetRoleByIdAsync(roleId) + ?? throw new KeyNotFoundException("Role not found"); + + if (data.ContainsKey("code")) + { + var code = data["code"].GetString(); + if (string.IsNullOrWhiteSpace(code)) + throw new ArgumentException("code cannot be empty"); + role.Code = code; + } + + if (data.ContainsKey("name")) + role.Name = data["name"].GetString(); + + if (data.ContainsKey("isSystemRole") && data["isSystemRole"].ValueKind != JsonValueKind.Null) + role.IsSystemRole = data["isSystemRole"].GetBoolean(); + + await _uow.CommitAsync(); + + return ToDto(role); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteRole(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data.ContainsKey("roleId") || !Guid.TryParse(data["roleId"].GetString(), out var roleId)) + throw new ArgumentException("Invalid roleId"); + + var role = await _repository.GetRoleByIdAsync(roleId) + ?? throw new KeyNotFoundException("Role not found"); + + //if (await _repository.RoleInUseAsync(roleId)) + // throw new InvalidOperationException("ROLE_IN_USE: role is assigned to one or more users"); + + await _repository.DeleteRoleAsync(role); + await _uow.CommitAsync(); + + return new { message = "Role deleted successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + private static object ToDto(Role role) => new + { + role.RoleId, + role.Code, + role.Name, + role.IsSystemRole, + role.CreatedAt + }; +} diff --git a/Services/UserManager/UserManagerService.cs b/Services/UserManager/UserManagerService.cs index f6f66cf..edbeb6e 100644 --- a/Services/UserManager/UserManagerService.cs +++ b/Services/UserManager/UserManagerService.cs @@ -28,9 +28,8 @@ public class UserManagerService private readonly AppDBContext _dbContext; private readonly ThirdPartyService _thirdPartyService; private readonly UserHelper _userHelper; - private readonly IConfiguration _configuration; - public UserManagerService(IUserManageRepository repository, IRecoveryManagerRepository recoveryRepository, IUnitOfWork uow, JwtTokenHelper jwt, AppDBContext dbContext, ThirdPartyService thirdPartyService, UserHelper userHelper, IConfiguration configuration) + public UserManagerService(IUserManageRepository repository, IRecoveryManagerRepository recoveryRepository, IUnitOfWork uow, JwtTokenHelper jwt, AppDBContext dbContext, ThirdPartyService thirdPartyService, UserHelper userHelper) { _repository = repository; _recoveryRepository = recoveryRepository; @@ -39,7 +38,6 @@ public class UserManagerService _dbContext = dbContext; _thirdPartyService = thirdPartyService; _userHelper = userHelper; - _configuration = configuration; } public async Task RegisterUser(object payload, HttpContext httpContext) @@ -65,27 +63,19 @@ public class UserManagerService var deviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : "Unknown Device"; - // A "roleId" in the payload (old ERP builds still send one) is accepted and ignored: roles are the ERP's business. - - if (!data.ContainsKey("userTypeId") || !Guid.TryParse(data["userTypeId"].GetString(), out var UserTypeId)) - throw new ArgumentException("Invalid userTypeId"); - + if (!data.ContainsKey("roleId") || !Guid.TryParse(data["roleId"].GetString(), out var RoleId)) + throw new ArgumentException("Invalid roleId"); //chk user exist - if (data.ContainsKey("chkUser") == true) { - var userToCheck = new User - { - FullName = FullName, - Email = Email, - MobileNumber = MobileNumber, - Nic = Nic, - UserTypeId = UserTypeId - }; - bool canSave = await CanRegisterUser(userToCheck); - - if (!canSave) - throw new InvalidOperationException("User already exists"); - } + var userToCheck = new User + { + Email = Email, + MobileNumber = MobileNumber, + Nic = Nic, + }; + bool canSave = await CanRegisterUser(userToCheck); + if (!canSave) + throw new InvalidOperationException("User already exists"); // if send password empty generate random password @@ -108,7 +98,7 @@ public class UserManagerService Nic = Nic, Email = Email, MobileNumber = MobileNumber, - UserTypeId = UserTypeId, + RoleCode = RoleId.ToString(), CreatedAt = DateTime.UtcNow, IsActive = true, IsLocked = false, @@ -191,7 +181,7 @@ public class UserManagerService user.EmailVerified, user.MobileNumberVerified, user.IsMfaEnabled, - UserTypeId = user.UserTypeId + RoleId = user.RoleCode } }; } @@ -219,13 +209,12 @@ public class UserManagerService var identifier = data.ContainsKey("identifier") ? data["identifier"].GetString() : null; var password = data.ContainsKey("password") ? data["password"].GetString() : null; - var userTypeId = data.ContainsKey("userTypeId") && Guid.TryParse(data["userTypeId"].GetString(), out var utid) ? utid : (Guid?)null; var deviceName = data.ContainsKey("deviceName") ? data["deviceName"].GetString() : "Unknown Device"; if (string.IsNullOrWhiteSpace(identifier)) throw new ArgumentException("Identifier and password are required"); - var user = await _repository.GetUserByIdentifierAndType(identifier, userTypeId); + var user = await _repository.GetUserByIdentifierAndType(identifier); @@ -299,7 +288,7 @@ public class UserManagerService user.EmailVerified, user.MobileNumberVerified, user.IsMfaEnabled, - UserTypeId = user.UserTypeId + RoleId = user.RoleCode } }; } @@ -407,7 +396,7 @@ public class UserManagerService user.EmailVerified, user.MobileNumberVerified, user.IsMfaEnabled, - UserTypeId = user.UserTypeId + RoleId = user.RoleCode } } }; @@ -499,7 +488,7 @@ public class UserManagerService user.EmailVerified, user.MobileNumberVerified, user.IsMfaEnabled, - UserTypeId = user.UserTypeId + RoleId = user.RoleCode } }; } @@ -545,12 +534,7 @@ public class UserManagerService user.IsActive, user.IsLocked, user.CreatedAt, - UserType = new - { - user.UserType?.UserTypeId, - user.UserType?.Code, - user.UserType?.Description - } + Role = user.RoleCode }; } catch @@ -559,17 +543,6 @@ public class UserManagerService } } - public async Task ListUserTypes(HttpContext httpContext) - { - var userTypes = await _dbContext.UserType.OrderBy(t => t.Code).ToListAsync(); - return userTypes.Select(t => new - { - t.UserTypeId, - t.Code, - t.Description - }).ToList(); - } - public async Task GetUserSessions(HttpContext httpContext) { try @@ -782,7 +755,8 @@ public class UserManagerService if (data == null || !data.Any()) throw new ArgumentException("Invalid payload data"); - var userId = _userHelper.GetUserIdFromClaims(httpContext); + if (!data.ContainsKey("userId") || !Guid.TryParse(data["userId"].GetString(), out var userId)) + throw new ArgumentException("Invalid userId"); var user = await _repository.GetUserByIdAsync(userId); @@ -851,6 +825,12 @@ public class UserManagerService updated = true; } + if (data.ContainsKey("roleCode")) + { + user.RoleCode = data["roleCode"].GetString(); + updated = true; + } + if (data.ContainsKey("newPassword")) { var currentPassword = data.ContainsKey("currentPassword") ? data["currentPassword"].GetString() : null; @@ -1199,7 +1179,7 @@ public class UserManagerService foreach (var user in existingUsers) { - if (user.UserTypeId == userToCheck.UserTypeId) + if (user.RoleCode == userToCheck.RoleCode) return false; if (user.IsLocked == false) @@ -1245,162 +1225,4 @@ public class UserManagerService return System.Text.RegularExpressions.Regex.IsMatch(cleaned, @"^\d{7,15}$"); } - // ---- Admin-only functions (require X-Admin-Api-Key) ---------------------------- - // Everything above acts on the caller's own account, resolved from the bearer token's - // claims. These three act on an arbitrary target user (by id, or by identifier for - // delete) on an admin's behalf — ERPCore calls them with a shared secret instead of a - // user bearer token, since the admin and the target are different people. - - private bool IsAuthorizedAdmin(HttpContext httpContext) - { - var expected = _configuration["AuthHex:AdminApiKey"]; - var provided = httpContext.Request.Headers["X-Admin-Api-Key"].FirstOrDefault(); - return !string.IsNullOrEmpty(expected) && provided == expected; - } - - public async Task AdminUpdateUser(object payload, HttpContext httpContext) - { - if (!IsAuthorizedAdmin(httpContext)) - throw new UnauthorizedAccessException("A valid X-Admin-Api-Key header is required for this operation."); - - var data = DeserializePayload.Deserialize(payload); - if (!data.ContainsKey("userId")) - throw new ArgumentException("userId is required"); - - var userId = Guid.Parse(data["userId"].GetString() ?? throw new ArgumentException("userId is required")); - var user = await _repository.GetUserByIdAsync(userId) - ?? throw new ArgumentException("User not found"); - - await _uow.BeginAsync(); - try - { - if (data.ContainsKey("fullName")) user.FullName = data["fullName"].GetString(); - if (data.ContainsKey("userName")) user.UserName = data["userName"].GetString(); - if (data.ContainsKey("email")) - { - user.Email = data["email"].GetString(); - user.EmailVerified = false; - } - if (data.ContainsKey("mobileNumber")) - { - user.MobileNumber = data["mobileNumber"].GetString(); - user.MobileNumberVerified = false; - } - - await _uow.CommitAsync(); - - return new - { - user.Id, - user.FullName, - user.UserName, - user.Email, - user.MobileNumber, - user.IsActive - }; - } - catch - { - await _uow.RollbackAsync(); - throw; - } - } - - public async Task AdminSetUserStatus(object payload, HttpContext httpContext) - { - if (!IsAuthorizedAdmin(httpContext)) - throw new UnauthorizedAccessException("A valid X-Admin-Api-Key header is required for this operation."); - - var data = DeserializePayload.Deserialize(payload); - if (!data.ContainsKey("userId")) - throw new ArgumentException("userId is required"); - - var userId = Guid.Parse(data["userId"].GetString() ?? throw new ArgumentException("userId is required")); - var isActive = data.ContainsKey("isActive") ? data["isActive"].GetBoolean() : (bool?)null; - if (isActive == null) - throw new ArgumentException("isActive is required"); - - var user = await _repository.GetUserByIdAsync(userId) - ?? throw new ArgumentException("User not found"); - - await _uow.BeginAsync(); - try - { - user.IsActive = isActive.Value; - - await _uow.CommitAsync(); - - // Deactivating must actually end any live sessions, not just flip a flag that - // existing refresh tokens ignore. - if (!isActive.Value) - await _repository.InvalidateUserSessionsAsync(user.Id); - - return new - { - user.Id, - user.FullName, - user.UserName, - user.Email, - user.IsActive - }; - } - catch - { - await _uow.RollbackAsync(); - throw; - } - } - - /// - /// Hard-deletes one or more users matched by any of the given identifiers. Sessions, - /// tokens, MFA and provider links cascade at the DB level (AppDBContext.OnModelCreating); - /// Recovery rows have a plain Restrict FK and must be removed explicitly first. - /// - public async Task DeleteUser(object payload, HttpContext httpContext) - { - if (!IsAuthorizedAdmin(httpContext)) - throw new UnauthorizedAccessException("A valid X-Admin-Api-Key header is required for this operation."); - - var data = DeserializePayload.Deserialize(payload); - string? email = data.ContainsKey("email") ? data["email"].GetString() : null; - string? mobileNumber = data.ContainsKey("mobileNumber") ? data["mobileNumber"].GetString() : null; - string? nic = data.ContainsKey("nic") ? data["nic"].GetString() : null; - string? userName = data.ContainsKey("userName") ? data["userName"].GetString() : null; - - if (string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(mobileNumber) - && string.IsNullOrWhiteSpace(nic) && string.IsNullOrWhiteSpace(userName)) - throw new ArgumentException("At least one of email, mobileNumber, nic or userName is required."); - - var matches = await _dbContext.Users - .Where(u => - (email != null && u.Email == email) || - (mobileNumber != null && u.MobileNumber == mobileNumber) || - (nic != null && u.Nic == nic) || - (userName != null && u.UserName == userName)) - .ToListAsync(); - - if (matches.Count == 0) - throw new ArgumentException("No matching user was found."); - - var ids = matches.Select(u => u.Id).ToList(); - var deleted = matches.Select(u => new { u.Id, u.FullName, u.UserName, u.Email }).ToList(); - - await _uow.BeginAsync(); - try - { - _dbContext.Recovery.RemoveRange(_dbContext.Recovery.Where(r => r.UserId != null && ids.Contains(r.UserId.Value))); - _dbContext.AuthEventLogs.RemoveRange(_dbContext.AuthEventLogs.Where(l => l.UserId != null && ids.Contains(l.UserId.Value))); - _dbContext.Users.RemoveRange(matches); - - await _uow.CommitAsync(); - - return new { deletedCount = deleted.Count, deleted }; - } - catch - { - await _uow.RollbackAsync(); - throw; - } - } - } diff --git a/Utility/JwtTokenHelper.cs b/Utility/JwtTokenHelper.cs index 87b2660..e86e279 100644 --- a/Utility/JwtTokenHelper.cs +++ b/Utility/JwtTokenHelper.cs @@ -22,20 +22,12 @@ public class JwtTokenHelper public async Task GenerateToken(User user) { - // Load the user's usertype if not already loaded. (No role: authorization belongs to the ERP, which reads the - // person's role from its own tables - the token only says who the person is.) - if (user.UserType == null) - { - await _dbContext.Entry(user) - .Reference(u => u.UserType) - .LoadAsync(); - } - var claims = new List { new Claim("UserId", user.Id.ToString()), - new Claim("UserTypeId", user.UserTypeId.ToString()), - new Claim("UserTypeCode", user.UserType?.Code ?? ""), + new Claim("RoleId", user.RoleCode ?? ""), + new Claim("RoleCode", user.RoleCode ?? ""), + new Claim("NIC", user.Nic ?? ""), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Iat, diff --git a/appsettings.json b/appsettings.json index f0a7fb8..696599b 100644 --- a/appsettings.json +++ b/appsettings.json @@ -13,10 +13,7 @@ }, "AllowedHosts": "*", "AppSettings": { - "FrontendUrl": "https://erp.hexdive.com" - }, - "AuthHex": { - "AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9" + "FrontendUrl": "http://localhost:3000" }, "Kestrel": { "Endpoints": {