Decouple UserType, add Role entity and management #6

Merged
ashan_rusiru merged 2 commits from change/newAuth into main 2026-10-19 12:57:59 +05:30
19 changed files with 393 additions and 297 deletions
Showing only changes of commit 15832d1ae3 - Show all commits
-5
View File
@@ -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`)
+5 -11
View File
@@ -8,8 +8,8 @@ namespace AuthHex
{
public AppDBContext(DbContextOptions<AppDBContext> options) : base(options) { }
public DbSet<Models.User> Users => Set<Models.User>();
public DbSet<Models.UserType> UserType => Set<Models.UserType>();
public DbSet<Models.UserSession> UserSessions => Set<Models.UserSession>();
public DbSet<Models.Role> Roles => Set<Models.Role>();
public DbSet<Models.UserSession> UserSessions => Set<Models.UserSession>();
public DbSet<Models.Token> Token => Set<Models.Token>();
@@ -68,19 +68,13 @@ namespace AuthHex
.HasForeignKey(up => up.UserId)
.OnDelete(DeleteBehavior.Cascade);
// Configure UserType primary key
modelBuilder.Entity<Models.UserType>()
.HasKey(ut => ut.UserTypeId);
// Configure Role primary key
modelBuilder.Entity<Models.Role>()
.HasKey(r => r.RoleId);
// Configure User primary key and relationships
modelBuilder.Entity<Models.User>()
.HasKey(u => u.Id);
modelBuilder.Entity<Models.User>()
.HasOne(u => u.UserType)
.WithMany()
.HasForeignKey(u => u.UserTypeId)
.OnDelete(DeleteBehavior.Restrict);
}
}
+10 -1
View File
@@ -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<IActionResult> RoleExecute([FromBody] ApiRequest request)
{
var response = await _roleManager.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
using AuthHex.Models;
namespace AuthHex.Interfaces
{
public interface IRoleManageRepository
{
Task<Role> AddRoleAsync(Role role, CancellationToken ct = default);
Task<Role?> GetRoleByIdAsync(Guid roleId, CancellationToken ct = default);
Task<List<Role>> ListRolesAsync(CancellationToken ct = default);
//Task<bool> RoleInUseAsync(Guid roleId, CancellationToken ct = default);
Task DeleteRoleAsync(Role role, CancellationToken ct = default);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ namespace AuthHex.Interfaces
Task<User> AddUserAsync(User user, CancellationToken ct = default);
Task<Token> AddTokenAsync(Token token, CancellationToken ct = default);
Task<List<User>> GetUserByIdentifiers(string? email, string? mobileNumber, string? nic, string? username, string? Optional1);
Task<User?> GetUserByIdentifierAndType(string identifier, Guid? userTypeId);
Task<User?> GetUserByIdentifierAndType(string identifier);
Task<User?> GetUserByIdAsync(Guid userId, CancellationToken ct = default);
Task UpdateUserAsync(User user, CancellationToken ct = default);
Task<List<UserSession>> GetUserSessionsAsync(Guid userId, CancellationToken ct = default);
+15
View File
@@ -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;
}
}
+2 -7
View File
@@ -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<UserSession>? Sessions { get; set; }
public ICollection<UserProvider>? Providers { get; set; }
}
-13
View File
@@ -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;
}
}
+3
View File
@@ -57,15 +57,18 @@ builder.Services.AddScoped<IUnitOfWork, EFUnitOfWork>();
builder.Services.AddScoped<IUserManageRepository, UserManageRepository>();
builder.Services.AddScoped<IRecoveryManagerRepository, RecoveryManagerRepository>();
builder.Services.AddScoped<IAltOptionManagerRepository, AltOptionManagerRepository>();
builder.Services.AddScoped<IRoleManageRepository, RoleManageRepository>();
builder.Services.AddScoped<UserHelper>();
builder.Services.AddScoped<UserManagerService>();
builder.Services.AddScoped<RecoveryManagerService>();
builder.Services.AddScoped<AuthHex.Services.AltOptionManager.AltOptionManagerService>();
builder.Services.AddScoped<AuthHex.Services.RoleManager.RoleManagerService>();
builder.Services.AddHttpClient<ThirdPartyService>();
builder.Services.AddScoped<JwtTokenHelper>();
builder.Services.AddScoped<AuthHex.Services.FunctionHandler.UserManager>();
builder.Services.AddScoped<AuthHex.Services.FunctionHandler.RecoveryManager>();
builder.Services.AddScoped<AuthHex.Services.FunctionHandler.AltOptionManager>();
builder.Services.AddScoped<AuthHex.Services.FunctionHandler.RoleManager>();
var configuredOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
var allowedOrigins = configuredOrigins
+43
View File
@@ -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<Role> AddRoleAsync(Role role, CancellationToken ct = default)
{
_dbContext.Roles.Add(role);
return Task.FromResult(role);
}
public async Task<Role?> GetRoleByIdAsync(Guid roleId, CancellationToken ct = default)
{
return await _dbContext.Roles.FirstOrDefaultAsync(r => r.RoleId == roleId, ct);
}
public async Task<List<Role>> ListRolesAsync(CancellationToken ct = default)
{
return await _dbContext.Roles.OrderBy(r => r.Code).ToListAsync(ct);
}
//public async Task<bool> 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;
}
}
}
+1 -3
View File
@@ -43,9 +43,8 @@ namespace AuthHex.Repos
}
public async Task<User?> GetUserByIdentifierAndType(string identifier, Guid? userTypeId)
public async Task<User?> GetUserByIdentifierAndType(string identifier)
{
User? user = await _dbContext.Users
.FirstOrDefaultAsync(u =>
u.Email == identifier ||
@@ -70,7 +69,6 @@ namespace AuthHex.Repos
public async Task<User?> GetUserByIdAsync(Guid userId, CancellationToken ct = default)
{
return await _dbContext.Users
.Include(u => u.UserType)
.FirstOrDefaultAsync(u => u.Id == userId, ct);
}
@@ -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
}
}
};
+72
View File
@@ -0,0 +1,72 @@
using AuthHex.Models.IOs;
using AuthHex.Services.RoleManager;
namespace AuthHex.Services.FunctionHandler
{
public class RoleManager
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _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<ApiResponse> 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
};
}
}
}
}
+1 -16
View File
@@ -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
@@ -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
+151
View File
@@ -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<object> 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<object> 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<object> ListRoles(HttpContext httpContext)
{
var roles = await _repository.ListRolesAsync();
return roles.Select(ToDto).ToList();
}
public async Task<object> 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<object> 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
};
}
+28 -206
View File
@@ -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<object> 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<object> 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<object> 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<object> 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<object> 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;
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<object> 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;
}
}
}
+3 -11
View File
@@ -22,20 +22,12 @@ public class JwtTokenHelper
public async Task<string> 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<Claim>
{
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,
+1 -4
View File
@@ -13,10 +13,7 @@
},
"AllowedHosts": "*",
"AppSettings": {
"FrontendUrl": "https://erp.hexdive.com"
},
"AuthHex": {
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
"FrontendUrl": "http://localhost:3000"
},
"Kestrel": {
"Endpoints": {