This commit is contained in:
Dhananjaya99
2026-07-18 23:42:58 +05:30
parent 80b130dffb
commit 92c4b14a6c
55 changed files with 8815 additions and 43 deletions
@@ -0,0 +1,28 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Role CRUD + permission assignment. AuthHex is the source of truth for Role
/// identity (docs/10 C.9 "shadow user" pattern, applied to Role): every write is
/// forwarded to AuthHex first, then mirrored into the local shadow <c>Role</c> row.
/// Permission assignment is purely local (ERPCore/UI concern, not an AuthHex one).
/// </summary>
public interface IRoleService
{
Task<PagedResponse<RoleDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<RoleDto>?> GetAsync(int roleId, CancellationToken ct = default);
Task<ETagged<RoleDto>> CreateAsync(CreateRoleRequest request, CancellationToken ct = default);
Task<ETagged<RoleDto>> UpdateAsync(int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int roleId, CancellationToken ct = default);
Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default);
Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default);
/// <summary>Resolves the nav codes a role (by AuthHex `RoleCode` claim) may see. Used by `GET /auth/me`.</summary>
Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Admin-facing user management: list/create/reassign-role against the local
/// shadow `User` table, orchestrating account creation in AuthHex too (see
/// <see cref="CreateUserRequest"/>).
/// </summary>
public interface IUserManagementService
{
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
}
+214
View File
@@ -0,0 +1,214 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class RoleService : IRoleService
{
private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IAuthHexClient _authHex;
private readonly IUnitOfWork _uow;
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IAuthHexClient authHex, IUnitOfWork uow)
{
_roles = roles;
_rolePermissions = rolePermissions;
_permissions = permissions;
_authHex = authHex;
_uow = uow;
}
public async Task<PagedResponse<RoleDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _roles.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.Code, $"%{term}%") || EF.Functions.ILike(r.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(r => r.Code)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
return PagedResponse<RoleDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<RoleDto>?> GetAsync(int roleId, CancellationToken ct = default)
{
var role = await _roles.Query().AsNoTracking().FirstOrDefaultAsync(r => r.RoleId == roleId, ct);
return role is null ? null : new ETagged<RoleDto>(Map(role), role.RowVersion);
}
public async Task<ETagged<RoleDto>> CreateAsync(CreateRoleRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _roles.Query().AnyAsync(r => r.Code == code, ct))
throw new ConflictException($"A role with code '{code}' already exists.");
var authRole = await _authHex.CreateRoleAsync(
new CreateAuthHexRoleRequest { Code = code, Name = request.Name.Trim() }, ct);
var role = new Role
{
AuthRoleId = authRole.RoleId,
Code = code,
Name = request.Name.Trim(),
IsSystemRole = authRole.IsSystemRole ?? false,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _roles.AddAsync(role, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<RoleDto>(Map(role), role.RowVersion);
}
public async Task<ETagged<RoleDto>> UpdateAsync(
int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
if (role.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412);
var code = request.Code.Trim();
if (!string.Equals(role.Code, code, StringComparison.Ordinal)
&& await _roles.Query().AnyAsync(r => r.Code == code && r.RoleId != roleId, ct))
throw new ConflictException($"A role with code '{code}' already exists.");
await _authHex.UpdateRoleAsync(
new UpdateAuthHexRoleRequest { RoleId = role.AuthRoleId, Code = code, Name = request.Name.Trim() }, ct);
role.Code = code;
role.Name = request.Name.Trim();
role.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412);
}
return new ETagged<RoleDto>(Map(role), role.RowVersion);
}
public async Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default)
{
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
role.Status = status;
role.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int roleId, CancellationToken ct = default)
{
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
try
{
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
}
catch (DomainException ex) when (ex.Message.Contains("ROLE_IN_USE", StringComparison.OrdinalIgnoreCase))
{
throw new DomainException(ErrorCodes.RoleInUse, "This role is assigned to one or more users.", 409);
}
_roles.Remove(role);
await _uow.SaveChangesAsync(ct);
}
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
{
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
var granted = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == roleId)
.Include(rp => rp.Permission)
.Select(rp => rp.Permission!)
.ToListAsync(ct);
return new RolePermissionsDto(
roleId,
granted.Where(p => p.NavItemId is not null).Select(p => p.NavItemId!.Value).ToList(),
granted.Where(p => p.SubNavItemId is not null).Select(p => p.SubNavItemId!.Value).ToList());
}
public async Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default)
{
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
var existing = await _rolePermissions.Query().Where(rp => rp.RoleId == roleId).ToListAsync(ct);
foreach (var rp in existing) _rolePermissions.Remove(rp);
var navIds = request.NavItemIds.Distinct().ToList();
var subNavIds = request.SubNavItemIds.Distinct().ToList();
var permissionIds = await _permissions.Query().AsNoTracking()
.Where(p => (p.NavItemId != null && navIds.Contains(p.NavItemId.Value))
|| (p.SubNavItemId != null && subNavIds.Contains(p.SubNavItemId.Value)))
.Select(p => p.PermissionId)
.ToListAsync(ct);
foreach (var permissionId in permissionIds)
await _rolePermissions.AddAsync(new RolePermission { RoleId = roleId, PermissionId = permissionId }, ct);
await _uow.SaveChangesAsync(ct);
return await GetPermissionsAsync(roleId, ct);
}
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(roleCode))
return new MeResponseDto(null, null, Array.Empty<string>());
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code == roleCode, ct);
if (role is null)
return new MeResponseDto(roleCode, null, Array.Empty<string>());
var permissions = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
.Select(rp => rp.Permission!)
.ToListAsync(ct);
var navCodes = permissions
.Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code)
.Where(code => code is not null)
.Select(code => code!)
.Distinct()
.ToList();
return new MeResponseDto(role.Code, role.Name, navCodes);
}
private static RoleDto Map(Role r) => new(
r.RoleId, r.Code, r.Name, r.IsSystemRole, r.Status, r.CreatedAt, r.UpdatedAt);
}
@@ -0,0 +1,124 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class UserManagementService : IUserManagementService
{
private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles;
private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex;
private readonly IUnitOfWork _uow;
public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow)
{
_users = users;
_roles = roles;
_authUsers = authUsers;
_authHex = authHex;
_uow = uow;
}
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Username)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
return PagedResponse<ManagedUserDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default)
{
var user = await _users.Query().AsNoTracking().Include(u => u.Role)
.FirstOrDefaultAsync(u => u.UserId == userId, ct);
return user is null ? null : Map(user);
}
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default)
{
var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
var username = request.Username.Trim();
if (await _users.Query().AnyAsync(u => u.Username == username, ct))
throw new ConflictException($"A user with username '{username}' already exists.");
var authUserId = Guid.NewGuid();
// Source of truth: AuthHex creates the credential + emails it (registerUser,
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs).
await _authUsers.RegisterAsync(new RegisterRequest
{
UserId = authUserId,
RoleId = role.AuthRoleId,
UserTypeId = request.UserTypeId,
Fullname = request.FullName.Trim(),
UserName = username,
Nic = request.Nic,
Email = request.Email.Trim(),
MobileNumber = request.MobileNumber,
Password = request.Password,
ChkUser = true
}, ct);
// Mirror into the local shadow User row immediately, rather than waiting
// for ShadowUserClaimsTransformation's next-login JIT provisioning.
var user = new User
{
AuthUserId = authUserId,
Username = username,
DisplayName = request.FullName.Trim(),
RoleId = role.RoleId,
Status = EntityStatus.Active
};
await _users.AddAsync(user, ct);
await _uow.SaveChangesAsync(ct);
user.Role = role;
return Map(user);
}
public async Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
user.RoleId = role.RoleId;
await _uow.SaveChangesAsync(ct);
user.Role = role;
return Map(user);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{
var userTypes = await _authHex.ListUserTypesAsync(ct);
return userTypes.Select(t => new UserTypeOptionDto(t.UserTypeId, t.Code, t.Description)).ToList();
}
private static ManagedUserDto Map(User u) => new(
u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name);
}