add rbac
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using ERPCore.Dtos.Auth;
|
using ERPCore.Dtos.Auth;
|
||||||
|
using ERPCore.Dtos.Rbac;
|
||||||
using ERPCore.Infra.Auth;
|
using ERPCore.Infra.Auth;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
@@ -25,12 +26,27 @@ public sealed class AuthController : ControllerBase
|
|||||||
private readonly IAuthUserService _users;
|
private readonly IAuthUserService _users;
|
||||||
private readonly IAuthRecoveryService _recovery;
|
private readonly IAuthRecoveryService _recovery;
|
||||||
private readonly IAuthAltService _alt;
|
private readonly IAuthAltService _alt;
|
||||||
|
private readonly IRoleService _roles;
|
||||||
|
|
||||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
|
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_recovery = recovery;
|
_recovery = recovery;
|
||||||
_alt = alt;
|
_alt = alt;
|
||||||
|
_roles = roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative current-session info for the frontend: role + the sidebar nav
|
||||||
|
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
|
||||||
|
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("me")]
|
||||||
|
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
|
||||||
|
return Ok(await _roles.GetMeAsync(roleCode, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Session-issuing (UserManager) ------------------------------------
|
// ---- Session-issuing (UserManager) ------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Rbac;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
|
||||||
|
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
|
||||||
|
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
|
||||||
|
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/nav")]
|
||||||
|
public sealed class NavController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRepository<NavItem> _navItems;
|
||||||
|
|
||||||
|
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var items = await _navItems.Query().AsNoTracking()
|
||||||
|
.Include(n => n.Children)
|
||||||
|
.OrderBy(n => n.SortOrder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var dto = items.Select(n => new NavItemDto(
|
||||||
|
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
|
||||||
|
n.Children.OrderBy(c => c.SortOrder)
|
||||||
|
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
|
||||||
|
.ToList())).ToList();
|
||||||
|
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Rbac;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
||||||
|
[Route("api/v1/roles")]
|
||||||
|
public sealed class RolesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRoleService _roles;
|
||||||
|
|
||||||
|
public RolesController(IRoleService roles) => _roles = roles;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _roles.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{roleId:int}")]
|
||||||
|
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _roles.GetAsync(roleId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _roles.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{roleId:int}")]
|
||||||
|
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{roleId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _roles.SetStatusAsync(roleId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{roleId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _roles.DeleteAsync(roleId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{roleId:int}/permissions")]
|
||||||
|
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
|
||||||
|
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
|
||||||
|
|
||||||
|
[HttpPut("{roleId:int}/permissions")]
|
||||||
|
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
|
||||||
|
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Users;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User management: local shadow `User` list/detail + role assignment, and
|
||||||
|
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/users")]
|
||||||
|
public sealed class UsersController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IUserManagementService _users;
|
||||||
|
|
||||||
|
public UsersController(IUserManagementService users) => _users = users;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _users.ListAsync(query, ct));
|
||||||
|
|
||||||
|
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||||
|
[HttpGet("user-types")]
|
||||||
|
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
|
||||||
|
=> Ok(await _users.ListUserTypesAsync(ct));
|
||||||
|
|
||||||
|
[HttpGet("{userId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _users.GetAsync(userId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _users.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/users/{result.UserId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{userId:int}/role")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A top-level sidebar entry (mirrors the frontend's hardcoded nav list,
|
||||||
|
/// components/Layouts/AppSidebar.tsx). Seeded to match the current app routes;
|
||||||
|
/// per-role visibility is controlled via <see cref="Permission"/>/<see cref="RolePermission"/>,
|
||||||
|
/// not by editing these rows through the UI.
|
||||||
|
/// </summary>
|
||||||
|
public class NavItem
|
||||||
|
{
|
||||||
|
public int NavItemId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
public string? Href { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public ICollection<SubNavItem> Children { get; set; } = new List<SubNavItem>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A grantable sidebar-visibility unit — exactly one of <see cref="NavItemId"/> /
|
||||||
|
/// <see cref="SubNavItemId"/> is set (enforced in <c>NavSeedService</c>/service layer,
|
||||||
|
/// not by a DB constraint). One row is seeded per <see cref="NavItem"/>/<see cref="SubNavItem"/>;
|
||||||
|
/// <see cref="RolePermission"/> grants it to a role.
|
||||||
|
/// </summary>
|
||||||
|
public class Permission
|
||||||
|
{
|
||||||
|
public int PermissionId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public int? NavItemId { get; set; }
|
||||||
|
public int? SubNavItemId { get; set; }
|
||||||
|
|
||||||
|
public NavItem? NavItem { get; set; }
|
||||||
|
public SubNavItem? SubNavItem { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
|
||||||
|
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
|
||||||
|
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
|
||||||
|
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
|
||||||
|
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
|
||||||
|
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
|
||||||
|
/// </summary>
|
||||||
|
public class Role
|
||||||
|
{
|
||||||
|
public int RoleId { get; set; }
|
||||||
|
public Guid AuthRoleId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public bool IsSystemRole { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
|
||||||
|
public class RolePermission
|
||||||
|
{
|
||||||
|
public int RoleId { get; set; }
|
||||||
|
public int PermissionId { get; set; }
|
||||||
|
|
||||||
|
public Role? Role { get; set; }
|
||||||
|
public Permission? Permission { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>A child sidebar entry under a <see cref="NavItem"/> (e.g. Products' children).</summary>
|
||||||
|
public class SubNavItem
|
||||||
|
{
|
||||||
|
public int SubNavItemId { get; set; }
|
||||||
|
public int NavItemId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
public string? Href { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public NavItem? NavItem { get; set; }
|
||||||
|
}
|
||||||
@@ -22,4 +22,8 @@ public class User
|
|||||||
|
|
||||||
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
|
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
|
||||||
public Guid? AuthUserId { get; set; }
|
public Guid? AuthUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
|
||||||
|
public int? RoleId { get; set; }
|
||||||
|
public Role? Role { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Auth;
|
||||||
|
|
||||||
|
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
|
||||||
|
public sealed class AuthHexRoleDto
|
||||||
|
{
|
||||||
|
public Guid RoleId { get; set; }
|
||||||
|
public string? Code { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public bool? IsSystemRole { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateAuthHexRoleRequest
|
||||||
|
{
|
||||||
|
[Required] public string Code { get; set; } = string.Empty;
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public bool? IsSystemRole { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateAuthHexRoleRequest
|
||||||
|
{
|
||||||
|
[Required] public Guid RoleId { get; set; }
|
||||||
|
public string? Code { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public bool? IsSystemRole { get; set; }
|
||||||
|
}
|
||||||
@@ -82,6 +82,14 @@ public sealed class GetUserDetailsResponse
|
|||||||
public JsonElement? UserType { get; set; }
|
public JsonElement? UserType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AuthHex's UserType lookup (ERP_Auth_Service/API_DOCUMENTATION.md, listUserTypes).</summary>
|
||||||
|
public sealed class UserTypeDto
|
||||||
|
{
|
||||||
|
public Guid UserTypeId { get; set; }
|
||||||
|
public string? Code { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class SessionDto
|
public sealed class SessionDto
|
||||||
{
|
{
|
||||||
public string? SessionId { get; set; }
|
public string? SessionId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ERPCore.Dtos.Rbac;
|
||||||
|
|
||||||
|
/// <summary>Response for `GET /api/v1/auth/me` — the frontend's authoritative source
|
||||||
|
/// for the current user's role and permitted sidebar nav codes (replaces trusting
|
||||||
|
/// the stale, client-only `roleId` cached in localStorage).</summary>
|
||||||
|
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ERPCore.Dtos.Rbac;
|
||||||
|
|
||||||
|
public sealed record SubNavItemDto(int SubNavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder);
|
||||||
|
|
||||||
|
public sealed record NavItemDto(
|
||||||
|
int NavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder,
|
||||||
|
IReadOnlyList<SubNavItemDto> Children);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Rbac;
|
||||||
|
|
||||||
|
public sealed record RoleDto(
|
||||||
|
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
|
||||||
|
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
|
public sealed class CreateRoleRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateRoleRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||||
|
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateRoleStatusRequest
|
||||||
|
{
|
||||||
|
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces a role's full permission set (checkbox-tree save from the frontend).</summary>
|
||||||
|
public sealed class AssignRolePermissionsRequest
|
||||||
|
{
|
||||||
|
public List<int> NavItemIds { get; set; } = new();
|
||||||
|
public List<int> SubNavItemIds { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RolePermissionsDto(int RoleId, List<int> NavItemIds, List<int> SubNavItemIds);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Dtos.Users;
|
||||||
|
|
||||||
|
public sealed record ManagedUserDto(
|
||||||
|
int UserId, string Username, string DisplayName, EntityStatus Status,
|
||||||
|
int? RoleId, string? RoleCode, string? RoleName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a user in both backends: forwards to AuthHex's `registerUser` (source
|
||||||
|
/// of truth for credentials), then mirrors the account into ERPCore's local
|
||||||
|
/// shadow `User` row immediately (rather than waiting for next-login JIT
|
||||||
|
/// provisioning). AuthHex emails the generated/supplied password to `Email`.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateUserRequest
|
||||||
|
{
|
||||||
|
[Required, StringLength(100)] public string Username { get; set; } = string.Empty;
|
||||||
|
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
|
||||||
|
[Required] public int RoleId { get; set; }
|
||||||
|
[Required] public Guid UserTypeId { get; set; }
|
||||||
|
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
|
||||||
|
public string? Nic { get; set; }
|
||||||
|
public string? MobileNumber { get; set; }
|
||||||
|
/// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary>
|
||||||
|
public string? Password { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateUserRoleRequest
|
||||||
|
{
|
||||||
|
[Required] public int RoleId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using ERPCore.Dtos.Auth;
|
using ERPCore.Dtos.Auth;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
|
||||||
@@ -16,9 +17,14 @@ namespace ERPCore.Infra.Auth.AuthHex;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuthHexClient : IAuthHexClient
|
public sealed class AuthHexClient : IAuthHexClient
|
||||||
{
|
{
|
||||||
|
// WhenWritingNull: AuthHex's dispatcher reads payload fields as raw JsonElements and some
|
||||||
|
// (e.g. RoleManager's isSystemRole) call type-specific getters like GetBoolean() that throw
|
||||||
|
// on an explicit JSON null rather than treating it as "absent" — omit null properties instead
|
||||||
|
// of serializing them, so unset nullable request fields behave as ContainsKey == false upstream.
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
@@ -42,6 +48,9 @@ public sealed class AuthHexClient : IAuthHexClient
|
|||||||
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct)
|
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct)
|
||||||
=> CallAsync<GetUserDetailsResponse>("user", "getUserDetails", new { userId }, null, ct);
|
=> CallAsync<GetUserDetailsResponse>("user", "getUserDetails", new { userId }, null, ct);
|
||||||
|
|
||||||
|
public Task<List<UserTypeDto>> ListUserTypesAsync(CancellationToken ct)
|
||||||
|
=> CallAsync<List<UserTypeDto>>("user", "listUserTypes", new { }, null, ct);
|
||||||
|
|
||||||
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
|
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
|
||||||
=> CallAsync<List<SessionDto>>("user", "getUserSessions", new { }, bearerToken, ct);
|
=> CallAsync<List<SessionDto>>("user", "getUserSessions", new { }, bearerToken, ct);
|
||||||
|
|
||||||
@@ -103,6 +112,23 @@ public sealed class AuthHexClient : IAuthHexClient
|
|||||||
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
|
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
|
||||||
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
|
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
|
||||||
|
|
||||||
|
// ---- RoleManager --------------------------------------------------
|
||||||
|
|
||||||
|
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
|
||||||
|
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
|
||||||
|
|
||||||
|
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
|
||||||
|
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
|
||||||
|
|
||||||
|
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
|
||||||
|
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
|
||||||
|
|
||||||
|
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
|
||||||
|
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
|
||||||
|
|
||||||
|
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
|
||||||
|
=> CallVoidAsync("role", "deleteRole", new { roleId }, null, ct);
|
||||||
|
|
||||||
// ---- Transport --------------------------------------------------------
|
// ---- Transport --------------------------------------------------------
|
||||||
|
|
||||||
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public interface IAuthHexClient
|
|||||||
Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
|
Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
|
||||||
Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
|
Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
|
||||||
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct);
|
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct);
|
||||||
|
Task<List<UserTypeDto>> ListUserTypesAsync(CancellationToken ct);
|
||||||
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
|
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
|
||||||
Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
|
Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
|
||||||
Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
|
Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
|
||||||
@@ -39,4 +40,12 @@ public interface IAuthHexClient
|
|||||||
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
|
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
|
||||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
|
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
|
||||||
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
|
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
|
||||||
|
|
||||||
|
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
|
||||||
|
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
|
||||||
|
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
|
||||||
|
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
|
||||||
|
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
|
||||||
|
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
|
||||||
|
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeded to mirror the frontend's hardcoded sidebar
|
||||||
|
/// (ERP-core/Frontend/erp-system/components/Layouts/AppSidebar.tsx). Codes here
|
||||||
|
/// must match the <c>code</c> given to each frontend nav entry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<NavItem> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("nav_items");
|
||||||
|
builder.HasKey(n => n.NavItemId);
|
||||||
|
|
||||||
|
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
|
||||||
|
builder.HasIndex(n => n.Code).IsUnique();
|
||||||
|
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
|
||||||
|
builder.Property(n => n.Icon).HasMaxLength(50);
|
||||||
|
builder.Property(n => n.Href).HasMaxLength(200);
|
||||||
|
builder.Property(n => n.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|
||||||
|
builder.HasData(
|
||||||
|
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
|
||||||
|
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
|
||||||
|
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
|
||||||
|
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
|
||||||
|
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
|
||||||
|
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
|
||||||
|
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
|
||||||
|
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
|
||||||
|
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
|
||||||
|
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per <see cref="NavItem"/>/<see cref="SubNavItem"/>, seeded in lockstep
|
||||||
|
/// with <see cref="NavItemConfiguration"/>/<see cref="SubNavItemConfiguration"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permission>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Permission> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("permissions");
|
||||||
|
builder.HasKey(p => p.PermissionId);
|
||||||
|
|
||||||
|
builder.Property(p => p.Code).IsRequired().HasMaxLength(80);
|
||||||
|
builder.HasIndex(p => p.Code).IsUnique();
|
||||||
|
|
||||||
|
builder.HasOne(p => p.NavItem).WithMany()
|
||||||
|
.HasForeignKey(p => p.NavItemId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne(p => p.SubNavItem).WithMany()
|
||||||
|
.HasForeignKey(p => p.SubNavItemId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasData(
|
||||||
|
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
|
||||||
|
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
|
||||||
|
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
|
||||||
|
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
|
||||||
|
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
|
||||||
|
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
|
||||||
|
new Permission { PermissionId = 7, Code = "NAV:warehouses", NavItemId = 7 },
|
||||||
|
new Permission { PermissionId = 8, Code = "NAV:orders", NavItemId = 8 },
|
||||||
|
new Permission { PermissionId = 9, Code = "NAV:settings", NavItemId = 9 },
|
||||||
|
new Permission { PermissionId = 10, Code = "NAV:help", NavItemId = 10 },
|
||||||
|
new Permission { PermissionId = 11, Code = "NAV:products.item", SubNavItemId = 1 },
|
||||||
|
new Permission { PermissionId = 12, Code = "NAV:products.category", SubNavItemId = 2 },
|
||||||
|
new Permission { PermissionId = 13, Code = "NAV:products.brand", SubNavItemId = 3 },
|
||||||
|
new Permission { PermissionId = 14, Code = "NAV:products.item-type", SubNavItemId = 4 },
|
||||||
|
new Permission { PermissionId = 15, Code = "NAV:products.uom", SubNavItemId = 5 },
|
||||||
|
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
|
||||||
|
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
|
||||||
|
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Role> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("roles");
|
||||||
|
builder.HasKey(r => r.RoleId);
|
||||||
|
|
||||||
|
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id").IsRequired();
|
||||||
|
builder.HasIndex(r => r.AuthRoleId).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(r => r.Code).IsRequired().HasMaxLength(50);
|
||||||
|
builder.HasIndex(r => r.Code).IsUnique();
|
||||||
|
|
||||||
|
builder.Property(r => r.Name).IsRequired().HasMaxLength(200);
|
||||||
|
builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false);
|
||||||
|
|
||||||
|
builder.Property(r => r.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|
||||||
|
builder.Property(r => r.CreatedAt).IsRequired();
|
||||||
|
|
||||||
|
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
|
||||||
|
builder.Property(r => r.RowVersion).IsRowVersion();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class RolePermissionConfiguration : IEntityTypeConfiguration<RolePermission>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RolePermission> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("role_permissions");
|
||||||
|
builder.HasKey(rp => new { rp.RoleId, rp.PermissionId });
|
||||||
|
|
||||||
|
builder.HasOne(rp => rp.Role).WithMany()
|
||||||
|
.HasForeignKey(rp => rp.RoleId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne(rp => rp.Permission).WithMany()
|
||||||
|
.HasForeignKey(rp => rp.PermissionId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavItem>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<SubNavItem> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("sub_nav_items");
|
||||||
|
builder.HasKey(n => n.SubNavItemId);
|
||||||
|
|
||||||
|
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
|
||||||
|
builder.HasIndex(n => n.Code).IsUnique();
|
||||||
|
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
|
||||||
|
builder.Property(n => n.Icon).HasMaxLength(50);
|
||||||
|
builder.Property(n => n.Href).HasMaxLength(200);
|
||||||
|
builder.Property(n => n.Status)
|
||||||
|
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||||
|
.HasDefaultValue(EntityStatus.Active);
|
||||||
|
|
||||||
|
builder.HasOne(n => n.NavItem).WithMany(n => n.Children)
|
||||||
|
.HasForeignKey(n => n.NavItemId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.HasData(
|
||||||
|
new SubNavItem { SubNavItemId = 1, NavItemId = 2, Code = "products.item", Label = "Item", Href = "/dashboard/products", SortOrder = 1 },
|
||||||
|
new SubNavItem { SubNavItemId = 2, NavItemId = 2, Code = "products.category", Label = "Category", Href = "/dashboard/products/categories", SortOrder = 2 },
|
||||||
|
new SubNavItem { SubNavItemId = 3, NavItemId = 2, Code = "products.brand", Label = "Brand", Href = "/dashboard/products/brands", SortOrder = 3 },
|
||||||
|
new SubNavItem { SubNavItemId = 4, NavItemId = 2, Code = "products.item-type", Label = "Item Type", Href = "/dashboard/products/item-types", SortOrder = 4 },
|
||||||
|
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
|
||||||
|
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
|
||||||
|
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
|
||||||
|
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
|||||||
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
|
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
|
||||||
builder.HasIndex(u => u.AuthUserId).IsUnique();
|
builder.HasIndex(u => u.AuthUserId).IsUnique();
|
||||||
|
|
||||||
|
// Local shadow Role assignment (nullable — unset until an admin assigns one).
|
||||||
|
builder.HasOne(u => u.Role).WithMany()
|
||||||
|
.HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
// Seeded fallback audit actor while auth is deferred (§6).
|
// Seeded fallback audit actor while auth is deferred (§6).
|
||||||
builder.HasData(new User
|
builder.HasData(new User
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ public class ErpDbContext : DbContext
|
|||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<NumberSequence> NumberSequences => Set<NumberSequence>();
|
public DbSet<NumberSequence> NumberSequences => Set<NumberSequence>();
|
||||||
|
|
||||||
|
// --- RBAC / sidebar (docs/10 Part C.8) ---
|
||||||
|
public DbSet<Role> Roles => Set<Role>();
|
||||||
|
public DbSet<NavItem> NavItems => Set<NavItem>();
|
||||||
|
public DbSet<SubNavItem> SubNavItems => Set<SubNavItem>();
|
||||||
|
public DbSet<Permission> Permissions => Set<Permission>();
|
||||||
|
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||||
|
|
||||||
// --- Procurement (docs/10 Part C.2) ---
|
// --- Procurement (docs/10 Part C.2) ---
|
||||||
public DbSet<Requisition> Requisitions => Set<Requisition>();
|
public DbSet<Requisition> Requisitions => Set<Requisition>();
|
||||||
public DbSet<RequisitionLine> RequisitionLines => Set<RequisitionLine>();
|
public DbSet<RequisitionLine> RequisitionLines => Set<RequisitionLine>();
|
||||||
|
|||||||
+2454
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ini2 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3001
File diff suppressed because it is too large
Load Diff
+303
@@ -0,0 +1,303 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace ERPCore.Infra.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRolesNavPermissions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RoleId",
|
||||||
|
table: "users",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "nav_items",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_nav_items", x => x.NavItemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "roles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
RoleId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_roles", x => x.RoleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sub_nav_items",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
NavItemId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||||
|
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_sub_nav_items_nav_items_NavItemId",
|
||||||
|
column: x => x.NavItemId,
|
||||||
|
principalTable: "nav_items",
|
||||||
|
principalColumn: "NavItemId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "permissions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||||
|
NavItemId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
SubNavItemId = table.Column<int>(type: "integer", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_permissions", x => x.PermissionId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_permissions_nav_items_NavItemId",
|
||||||
|
column: x => x.NavItemId,
|
||||||
|
principalTable: "nav_items",
|
||||||
|
principalColumn: "NavItemId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_permissions_sub_nav_items_SubNavItemId",
|
||||||
|
column: x => x.SubNavItemId,
|
||||||
|
principalTable: "sub_nav_items",
|
||||||
|
principalColumn: "SubNavItemId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "role_permissions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
RoleId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
PermissionId = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_role_permissions_permissions_PermissionId",
|
||||||
|
column: x => x.PermissionId,
|
||||||
|
principalTable: "permissions",
|
||||||
|
principalColumn: "PermissionId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_role_permissions_roles_RoleId",
|
||||||
|
column: x => x.RoleId,
|
||||||
|
principalTable: "roles",
|
||||||
|
principalColumn: "RoleId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "nav_items",
|
||||||
|
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "dashboard", "/dashboard", null, "Dashboard", 1 },
|
||||||
|
{ 2, "products", "/dashboard/products", null, "Products", 2 },
|
||||||
|
{ 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 },
|
||||||
|
{ 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 },
|
||||||
|
{ 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 },
|
||||||
|
{ 6, "stock", "/dashboard/stock", null, "Stock", 6 },
|
||||||
|
{ 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 },
|
||||||
|
{ 8, "orders", "/dashboard/orders", null, "Orders", 8 },
|
||||||
|
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
|
||||||
|
{ 10, "help", "/dashboard/help", null, "Help", 10 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "users",
|
||||||
|
keyColumn: "UserId",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "RoleId",
|
||||||
|
value: null);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "NAV:dashboard", 1, null },
|
||||||
|
{ 2, "NAV:products", 2, null },
|
||||||
|
{ 3, "NAV:vendors", 3, null },
|
||||||
|
{ 4, "NAV:procurement", 4, null },
|
||||||
|
{ 5, "NAV:receiving", 5, null },
|
||||||
|
{ 6, "NAV:stock", 6, null },
|
||||||
|
{ 7, "NAV:warehouses", 7, null },
|
||||||
|
{ 8, "NAV:orders", 8, null },
|
||||||
|
{ 9, "NAV:settings", 9, null },
|
||||||
|
{ 10, "NAV:help", 10, null }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "sub_nav_items",
|
||||||
|
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 1, "products.item", "/dashboard/products", null, "Item", 2, 1 },
|
||||||
|
{ 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 },
|
||||||
|
{ 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 },
|
||||||
|
{ 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 },
|
||||||
|
{ 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 },
|
||||||
|
{ 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 },
|
||||||
|
{ 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 },
|
||||||
|
{ 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "permissions",
|
||||||
|
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ 11, "NAV:products.item", null, 1 },
|
||||||
|
{ 12, "NAV:products.category", null, 2 },
|
||||||
|
{ 13, "NAV:products.brand", null, 3 },
|
||||||
|
{ 14, "NAV:products.item-type", null, 4 },
|
||||||
|
{ 15, "NAV:products.uom", null, 5 },
|
||||||
|
{ 16, "NAV:products.configuration", null, 6 },
|
||||||
|
{ 17, "NAV:settings.roles", null, 7 },
|
||||||
|
{ 18, "NAV:settings.users", null, 8 }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_users_RoleId",
|
||||||
|
table: "users",
|
||||||
|
column: "RoleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_nav_items_Code",
|
||||||
|
table: "nav_items",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_permissions_Code",
|
||||||
|
table: "permissions",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_permissions_NavItemId",
|
||||||
|
table: "permissions",
|
||||||
|
column: "NavItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_permissions_SubNavItemId",
|
||||||
|
table: "permissions",
|
||||||
|
column: "SubNavItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_role_permissions_PermissionId",
|
||||||
|
table: "role_permissions",
|
||||||
|
column: "PermissionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_roles_auth_role_id",
|
||||||
|
table: "roles",
|
||||||
|
column: "auth_role_id",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_roles_Code",
|
||||||
|
table: "roles",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_sub_nav_items_Code",
|
||||||
|
table: "sub_nav_items",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_sub_nav_items_NavItemId",
|
||||||
|
table: "sub_nav_items",
|
||||||
|
column: "NavItemId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_users_roles_RoleId",
|
||||||
|
table: "users",
|
||||||
|
column: "RoleId",
|
||||||
|
principalTable: "roles",
|
||||||
|
principalColumn: "RoleId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_users_roles_RoleId",
|
||||||
|
table: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "role_permissions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "permissions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "roles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sub_nav_items");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "nav_items");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_users_RoleId",
|
||||||
|
table: "users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RoleId",
|
||||||
|
table: "users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -524,6 +524,142 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("journal_entry_stubs", (string)null);
|
b.ToTable("journal_entry_stubs", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("NavItemId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("NavItemId"));
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Href")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.HasKey("NavItemId");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("nav_items", (string)null);
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 1,
|
||||||
|
Code = "dashboard",
|
||||||
|
Href = "/dashboard",
|
||||||
|
Label = "Dashboard",
|
||||||
|
SortOrder = 1,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 2,
|
||||||
|
Code = "products",
|
||||||
|
Href = "/dashboard/products",
|
||||||
|
Label = "Products",
|
||||||
|
SortOrder = 2,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 3,
|
||||||
|
Code = "vendors",
|
||||||
|
Href = "/dashboard/vendors",
|
||||||
|
Label = "Vendors",
|
||||||
|
SortOrder = 3,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 4,
|
||||||
|
Code = "procurement",
|
||||||
|
Href = "/dashboard/procurement",
|
||||||
|
Label = "Procurement",
|
||||||
|
SortOrder = 4,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 5,
|
||||||
|
Code = "receiving",
|
||||||
|
Href = "/dashboard/receiving/grn",
|
||||||
|
Label = "Receiving",
|
||||||
|
SortOrder = 5,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 6,
|
||||||
|
Code = "stock",
|
||||||
|
Href = "/dashboard/stock",
|
||||||
|
Label = "Stock",
|
||||||
|
SortOrder = 6,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 7,
|
||||||
|
Code = "warehouses",
|
||||||
|
Href = "/dashboard/warehouse",
|
||||||
|
Label = "Warehouses",
|
||||||
|
SortOrder = 7,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 8,
|
||||||
|
Code = "orders",
|
||||||
|
Href = "/dashboard/orders",
|
||||||
|
Label = "Orders",
|
||||||
|
SortOrder = 8,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 9,
|
||||||
|
Code = "settings",
|
||||||
|
Href = "/dashboard/settings",
|
||||||
|
Label = "Settings",
|
||||||
|
SortOrder = 9,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
NavItemId = 10,
|
||||||
|
Code = "help",
|
||||||
|
Href = "/dashboard/help",
|
||||||
|
Label = "Help",
|
||||||
|
SortOrder = 10,
|
||||||
|
Status = "Active"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("SequenceId")
|
b.Property<int>("SequenceId")
|
||||||
@@ -554,6 +690,147 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("number_sequences", (string)null);
|
b.ToTable("number_sequences", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("PermissionId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PermissionId"));
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("character varying(80)");
|
||||||
|
|
||||||
|
b.Property<int?>("NavItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SubNavItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("PermissionId");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("NavItemId");
|
||||||
|
|
||||||
|
b.HasIndex("SubNavItemId");
|
||||||
|
|
||||||
|
b.ToTable("permissions", (string)null);
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 1,
|
||||||
|
Code = "NAV:dashboard",
|
||||||
|
NavItemId = 1
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 2,
|
||||||
|
Code = "NAV:products",
|
||||||
|
NavItemId = 2
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 3,
|
||||||
|
Code = "NAV:vendors",
|
||||||
|
NavItemId = 3
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 4,
|
||||||
|
Code = "NAV:procurement",
|
||||||
|
NavItemId = 4
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 5,
|
||||||
|
Code = "NAV:receiving",
|
||||||
|
NavItemId = 5
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 6,
|
||||||
|
Code = "NAV:stock",
|
||||||
|
NavItemId = 6
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 7,
|
||||||
|
Code = "NAV:warehouses",
|
||||||
|
NavItemId = 7
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 8,
|
||||||
|
Code = "NAV:orders",
|
||||||
|
NavItemId = 8
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 9,
|
||||||
|
Code = "NAV:settings",
|
||||||
|
NavItemId = 9
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 10,
|
||||||
|
Code = "NAV:help",
|
||||||
|
NavItemId = 10
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 11,
|
||||||
|
Code = "NAV:products.item",
|
||||||
|
SubNavItemId = 1
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 12,
|
||||||
|
Code = "NAV:products.category",
|
||||||
|
SubNavItemId = 2
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 13,
|
||||||
|
Code = "NAV:products.brand",
|
||||||
|
SubNavItemId = 3
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 14,
|
||||||
|
Code = "NAV:products.item-type",
|
||||||
|
SubNavItemId = 4
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 15,
|
||||||
|
Code = "NAV:products.uom",
|
||||||
|
SubNavItemId = 5
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 16,
|
||||||
|
Code = "NAV:products.configuration",
|
||||||
|
SubNavItemId = 6
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 17,
|
||||||
|
Code = "NAV:settings.roles",
|
||||||
|
SubNavItemId = 7
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
PermissionId = 18,
|
||||||
|
Code = "NAV:settings.users",
|
||||||
|
SubNavItemId = 8
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("PoLineId")
|
b.Property<int>("PoLineId")
|
||||||
@@ -942,6 +1219,78 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("rfq_lines", (string)null);
|
b.ToTable("rfq_lines", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.Role", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("RoleId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("RoleId"));
|
||||||
|
|
||||||
|
b.Property<Guid>("AuthRoleId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("auth_role_id");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystemRole")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<uint>("RowVersion")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("xid")
|
||||||
|
.HasColumnName("xmin");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("AuthRoleId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("roles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("RoleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("PermissionId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("RoleId", "PermissionId");
|
||||||
|
|
||||||
|
b.HasIndex("PermissionId");
|
||||||
|
|
||||||
|
b.ToTable("role_permissions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("SerialId")
|
b.Property<int>("SerialId")
|
||||||
@@ -1439,6 +1788,137 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.ToTable("subcategories", (string)null);
|
b.ToTable("subcategories", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SubNavItemId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SubNavItemId"));
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Href")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("NavItemId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Active");
|
||||||
|
|
||||||
|
b.HasKey("SubNavItemId");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("NavItemId");
|
||||||
|
|
||||||
|
b.ToTable("sub_nav_items", (string)null);
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 1,
|
||||||
|
Code = "products.item",
|
||||||
|
Href = "/dashboard/products",
|
||||||
|
Label = "Item",
|
||||||
|
NavItemId = 2,
|
||||||
|
SortOrder = 1,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 2,
|
||||||
|
Code = "products.category",
|
||||||
|
Href = "/dashboard/products/categories",
|
||||||
|
Label = "Category",
|
||||||
|
NavItemId = 2,
|
||||||
|
SortOrder = 2,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 3,
|
||||||
|
Code = "products.brand",
|
||||||
|
Href = "/dashboard/products/brands",
|
||||||
|
Label = "Brand",
|
||||||
|
NavItemId = 2,
|
||||||
|
SortOrder = 3,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 4,
|
||||||
|
Code = "products.item-type",
|
||||||
|
Href = "/dashboard/products/item-types",
|
||||||
|
Label = "Item Type",
|
||||||
|
NavItemId = 2,
|
||||||
|
SortOrder = 4,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 5,
|
||||||
|
Code = "products.uom",
|
||||||
|
Href = "/dashboard/products/uoms",
|
||||||
|
Label = "UOM",
|
||||||
|
NavItemId = 2,
|
||||||
|
SortOrder = 5,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 6,
|
||||||
|
Code = "products.configuration",
|
||||||
|
Href = "/dashboard/products/settings",
|
||||||
|
Label = "Configuration",
|
||||||
|
NavItemId = 2,
|
||||||
|
SortOrder = 6,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 7,
|
||||||
|
Code = "settings.roles",
|
||||||
|
Href = "/dashboard/settings/roles",
|
||||||
|
Label = "Roles",
|
||||||
|
NavItemId = 9,
|
||||||
|
SortOrder = 1,
|
||||||
|
Status = "Active"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
SubNavItemId = 8,
|
||||||
|
Code = "settings.users",
|
||||||
|
Href = "/dashboard/settings/users",
|
||||||
|
Label = "Users",
|
||||||
|
NavItemId = 9,
|
||||||
|
SortOrder = 2,
|
||||||
|
Status = "Active"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("UomId")
|
b.Property<int>("UomId")
|
||||||
@@ -1510,6 +1990,9 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("RoleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
@@ -1525,6 +2008,8 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.HasIndex("AuthUserId")
|
b.HasIndex("AuthUserId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
b.HasIndex("Username")
|
b.HasIndex("Username")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -1857,6 +2342,23 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Warehouse");
|
b.Navigation("Warehouse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("NavItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubNavItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("NavItem");
|
||||||
|
|
||||||
|
b.Navigation("SubNavItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
@@ -2049,6 +2551,25 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Rfq");
|
b.Navigation("Rfq");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Permission", "Permission")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PermissionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Permission");
|
||||||
|
|
||||||
|
b.Navigation("Role");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||||
@@ -2317,6 +2838,17 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("Category");
|
b.Navigation("Category");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("NavItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("NavItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
|
b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom")
|
||||||
@@ -2344,6 +2876,16 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("ToUom");
|
b.Navigation("ToUom");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ERPCore.Domain.Entities.Role", "Role")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("Role");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
|
b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq")
|
||||||
@@ -2399,6 +2941,11 @@ namespace ERPCore.Infra.Persistence.Migrations
|
|||||||
b.Navigation("UomConversions");
|
b.Navigation("UomConversions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
|||||||
builder.Services.AddScoped<IVendorService, VendorService>();
|
builder.Services.AddScoped<IVendorService, VendorService>();
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||||
|
|
||||||
|
// RBAC / sidebar: Role (shadow of AuthHex) + Permission assignment + user management
|
||||||
|
builder.Services.AddScoped<IRoleService, RoleService>();
|
||||||
|
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||||
|
|
||||||
// Cross-cutting + procurement services (docs/11 §3)
|
// Cross-cutting + procurement services (docs/11 §3)
|
||||||
builder.Services.AddScoped<INumberSequenceService, NumberSequenceService>();
|
builder.Services.AddScoped<INumberSequenceService, NumberSequenceService>();
|
||||||
builder.Services.AddScoped<IRequisitionService, RequisitionService>();
|
builder.Services.AddScoped<IRequisitionService, RequisitionService>();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ public static class ErrorCodes
|
|||||||
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
||||||
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY";
|
||||||
public const string ConfigDisabled = "CONFIG_DISABLED";
|
public const string ConfigDisabled = "CONFIG_DISABLED";
|
||||||
|
public const string RoleInUse = "ROLE_IN_USE";
|
||||||
|
|
||||||
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
||||||
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME"
|
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
|
||||||
},
|
},
|
||||||
"Auth": {
|
"Auth": {
|
||||||
"Issuer": "AuthHex",
|
"Issuer": "AuthHex",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"RequiredRoleCode": ""
|
"RequiredRoleCode": ""
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "CHANGE_ME"
|
"BaseUrl": "http://localhost:5011"
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AppSidebar } from "@/components/Layouts/AppSidebar"
|
|||||||
import { Header } from "@/components/Layouts/Header"
|
import { Header } from "@/components/Layouts/Header"
|
||||||
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
|
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
|
||||||
import { Toaster } from "@/components/ui/toast"
|
import { Toaster } from "@/components/ui/toast"
|
||||||
|
import { AuthProvider } from "@/components/auth/AuthProvider"
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -9,22 +10,24 @@ export default function DashboardLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<AuthProvider>
|
||||||
<AppSidebar />
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
<main className="flex flex-1 flex-col">
|
<AppSidebar />
|
||||||
<Header />
|
<main className="flex flex-1 flex-col">
|
||||||
<div className="flex-1 overflow-auto">
|
<Header />
|
||||||
<div className="p-6 lg:p-8">
|
<div className="flex-1 overflow-auto">
|
||||||
<Breadcrumbs />
|
<div className="p-6 lg:p-8">
|
||||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
<Breadcrumbs />
|
||||||
<div className="p-6">
|
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
||||||
{children}
|
<div className="p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
<Toaster />
|
||||||
<Toaster />
|
</div>
|
||||||
</div>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||||
|
|
||||||
|
import { navApi } from "@/lib/api/nav"
|
||||||
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { NavItem, Role } from "@/types/rbac"
|
||||||
|
|
||||||
|
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export default function RoleDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const roleId = Number(params.id)
|
||||||
|
|
||||||
|
const [role, setRole] = useState<Role | null>(null)
|
||||||
|
const [etag, setEtag] = useState<string | null>(null)
|
||||||
|
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [code, setCode] = useState("")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||||
|
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [conflict, setConflict] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoadError(null)
|
||||||
|
Promise.all([rolesApi.get(roleId), navApi.tree(), rolesApi.getPermissions(roleId)])
|
||||||
|
.then(([roleResult, navTree, permissions]) => {
|
||||||
|
setRole(roleResult.data)
|
||||||
|
setEtag(roleResult.etag)
|
||||||
|
setCode(roleResult.data.code)
|
||||||
|
setName(roleResult.data.name)
|
||||||
|
setTree(navTree)
|
||||||
|
setNavItemIds(new Set(permissions.navItemIds))
|
||||||
|
setSubNavItemIds(new Set(permissions.subNavItemIds))
|
||||||
|
setConflict(false)
|
||||||
|
})
|
||||||
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Number.isFinite(roleId)) load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roleId])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaveError(null)
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!code.trim()) nextErrors.code = "Role code is required"
|
||||||
|
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0 || !etag) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await rolesApi.update(roleId, { code: code.trim().toUpperCase(), name: name.trim() }, etag)
|
||||||
|
setRole(result.data)
|
||||||
|
setEtag(result.etag)
|
||||||
|
toast.success("Role saved", `${result.data.code} — ${result.data.name}`)
|
||||||
|
} catch (err) {
|
||||||
|
const errCode = (err as { code?: string })?.code
|
||||||
|
if (errCode === "CONCURRENCY_CONFLICT") {
|
||||||
|
setConflict(true)
|
||||||
|
setSaveError(errorMessage(err))
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fe = fieldErrors(err)
|
||||||
|
if (fe?.code) setErrors({ code: fe.code })
|
||||||
|
setSaveError(errorMessage(err))
|
||||||
|
toast.error("Could not save role", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePermissions() {
|
||||||
|
setSavingPermissions(true)
|
||||||
|
try {
|
||||||
|
await rolesApi.assignPermissions(roleId, {
|
||||||
|
navItemIds: Array.from(navItemIds),
|
||||||
|
subNavItemIds: Array.from(subNavItemIds),
|
||||||
|
})
|
||||||
|
toast.success("Permissions saved", "This role's visible sidebar sections have been updated.")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not save permissions", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSavingPermissions(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError && !role) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||||
|
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
|
Back to roles
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!role || !tree) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Skeleton className="h-10 w-72" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||||
|
<ArrowLeft className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
|
role.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-base text-muted-foreground">{role.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conflict && (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||||
|
<AlertTriangle className="size-5 shrink-0" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>{saveError ?? "This role was changed by someone else."} Reload before retrying.</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={load}>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{saveError && !conflict && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Code</Label>
|
||||||
|
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">Name</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving || conflict}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{saving ? "Saving…" : "Save details"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
||||||
|
</div>
|
||||||
|
<RolePermissionTree
|
||||||
|
tree={tree}
|
||||||
|
selectedNavItemIds={navItemIds}
|
||||||
|
selectedSubNavItemIds={subNavItemIds}
|
||||||
|
onChange={(nav, sub) => {
|
||||||
|
setNavItemIds(nav)
|
||||||
|
setSubNavItemIds(sub)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSavePermissions} disabled={savingPermissions}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{savingPermissions ? "Saving…" : "Save permissions"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" onClick={() => router.push("/dashboard/settings/roles")}>
|
||||||
|
Back to roles
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Pencil, Plus, ShieldCheck, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
|
import { navApi } from "@/lib/api/nav"
|
||||||
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { PaginationMeta } from "@/types/common"
|
||||||
|
import { NavItem, Role } from "@/types/rbac"
|
||||||
|
|
||||||
|
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
||||||
|
function deriveCode(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
const [roles, setRoles] = useState<Role[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||||
|
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
rolesApi
|
||||||
|
.list({ page, pageSize: PAGE_SIZE })
|
||||||
|
.then((res) => {
|
||||||
|
setRoles(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navApi.tree().then(setTree).catch(() => setTree([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setName("")
|
||||||
|
setNavItemIds(new Set())
|
||||||
|
setSubNavItemIds(new Set())
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = deriveCode(name)
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||||
|
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const result = await rolesApi.create({ code, name: name.trim() })
|
||||||
|
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
|
||||||
|
await rolesApi.assignPermissions(result.data.roleId, {
|
||||||
|
navItemIds: Array.from(navItemIds),
|
||||||
|
subNavItemIds: Array.from(subNavItemIds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
|
||||||
|
setOpen(false)
|
||||||
|
resetForm()
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not create role", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(role: Role) {
|
||||||
|
setActionPendingId(role.roleId)
|
||||||
|
try {
|
||||||
|
await rolesApi.remove(role.roleId)
|
||||||
|
toast.success("Role deleted", `${role.code} has been removed.`)
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not delete role", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setActionPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
Manage roles and which sidebar sections each one can see.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setOpen(v)
|
||||||
|
if (!v) resetForm()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button size="lg">
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New Role
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>New role</DialogTitle>
|
||||||
|
<DialogDescription>Created in both the auth service and here.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.name}>
|
||||||
|
<FieldLabel htmlFor="r-name">Name</FieldLabel>
|
||||||
|
<Input id="r-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Manager" aria-invalid={!!errors.name} />
|
||||||
|
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="r-code">Code (auto-generated)</FieldLabel>
|
||||||
|
<Input id="r-code" value={code} disabled readOnly placeholder="—" className="text-muted-foreground" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<FieldLabel>Sidebar permissions</FieldLabel>
|
||||||
|
{tree === null ? (
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
) : (
|
||||||
|
<div className="max-h-80 overflow-auto">
|
||||||
|
<RolePermissionTree
|
||||||
|
tree={tree}
|
||||||
|
selectedNavItemIds={navItemIds}
|
||||||
|
selectedSubNavItemIds={subNavItemIds}
|
||||||
|
onChange={(nav, sub) => {
|
||||||
|
setNavItemIds(nav)
|
||||||
|
setSubNavItemIds(sub)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||||
|
{submitting ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && roles === null && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && roles !== null && roles.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<ShieldCheck className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No roles yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && roles !== null && roles.length > 0 && (
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<TableRow key={r.roleId}>
|
||||||
|
<TableCell className="px-3 py-3.5 font-medium">{r.code}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{r.name}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
|
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`Edit ${r.code}`}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
aria-label={`Delete ${r.code}`}
|
||||||
|
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent
|
||||||
|
variant="destructive"
|
||||||
|
title={`Delete ${r.code}?`}
|
||||||
|
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => handleDelete(r)}
|
||||||
|
/>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Save } from "lucide-react"
|
||||||
|
|
||||||
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
|
import { usersApi } from "@/lib/api/users"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Role } from "@/types/rbac"
|
||||||
|
import { ManagedUser } from "@/types/users"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export default function UserDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const userId = Number(params.id)
|
||||||
|
|
||||||
|
const [user, setUser] = useState<ManagedUser | null>(null)
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [roleId, setRoleId] = useState<string>("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoadError(null)
|
||||||
|
Promise.all([usersApi.get(userId), rolesApi.list({ pageSize: 200, status: "Active" })])
|
||||||
|
.then(([u, roleList]) => {
|
||||||
|
setUser(u)
|
||||||
|
setRoles(roleList.items)
|
||||||
|
setRoleId(u.roleId ? String(u.roleId) : "")
|
||||||
|
})
|
||||||
|
.catch((err) => setLoadError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Number.isFinite(userId)) load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!roleId) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await usersApi.updateRole(userId, { roleId: Number(roleId) })
|
||||||
|
setUser(result)
|
||||||
|
toast.success("Role updated", `${result.username} is now assigned to ${result.roleName}.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Could not update role", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError && !user) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||||
|
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||||
|
Back to users
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Skeleton className="h-10 w-72" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||||
|
<ArrowLeft className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
|
user.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:max-w-sm">
|
||||||
|
<Label className="text-base">Role</Label>
|
||||||
|
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||||
|
{r.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/settings/users")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" onClick={handleSave} disabled={saving || !roleId}>
|
||||||
|
<Save className="size-5" />
|
||||||
|
{saving ? "Saving…" : "Save role"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { rolesApi } from "@/lib/api/roles"
|
||||||
|
import { usersApi } from "@/lib/api/users"
|
||||||
|
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { PaginationMeta } from "@/types/common"
|
||||||
|
import { Role } from "@/types/rbac"
|
||||||
|
import { ManagedUser, UserTypeOption } from "@/types/users"
|
||||||
|
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
||||||
|
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [username, setUsername] = useState("")
|
||||||
|
const [fullName, setFullName] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [mobileNumber, setMobileNumber] = useState("")
|
||||||
|
const [nic, setNic] = useState("")
|
||||||
|
const [roleId, setRoleId] = useState<string>("")
|
||||||
|
const [userTypeId, setUserTypeId] = useState("")
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
usersApi
|
||||||
|
.list({ page, pageSize: PAGE_SIZE })
|
||||||
|
.then((res) => {
|
||||||
|
setUsers(res.items)
|
||||||
|
setPagination(res.pagination)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(load, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rolesApi.list({ pageSize: 200, status: "Active" }).then((res) => setRoles(res.items)).catch(() => setRoles([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
usersApi
|
||||||
|
.userTypes()
|
||||||
|
.then((types) => {
|
||||||
|
setUserTypes(types)
|
||||||
|
// Only one user type exists today (AuthHex's "Admin"/"dev" seed) — default to it
|
||||||
|
// so the admin never has to pick a raw GUID; the select still lets them switch
|
||||||
|
// if more types are added later.
|
||||||
|
if (types.length > 0) setUserTypeId((current) => current || types[0].userTypeId)
|
||||||
|
})
|
||||||
|
.catch(() => setUserTypes([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setUsername("")
|
||||||
|
setFullName("")
|
||||||
|
setEmail("")
|
||||||
|
setMobileNumber("")
|
||||||
|
setNic("")
|
||||||
|
setRoleId("")
|
||||||
|
setUserTypeId("")
|
||||||
|
setErrors({})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const nextErrors: Record<string, string> = {}
|
||||||
|
if (!username.trim()) nextErrors.username = "Username is required"
|
||||||
|
if (!fullName.trim()) nextErrors.fullName = "Full name is required"
|
||||||
|
if (!email.trim()) nextErrors.email = "Email is required"
|
||||||
|
if (!roleId) nextErrors.roleId = "Role is required"
|
||||||
|
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
|
||||||
|
setErrors(nextErrors)
|
||||||
|
if (Object.keys(nextErrors).length > 0) return
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const result = await usersApi.create({
|
||||||
|
username: username.trim(),
|
||||||
|
fullName: fullName.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
mobileNumber: mobileNumber || null,
|
||||||
|
nic: nic || null,
|
||||||
|
roleId: Number(roleId),
|
||||||
|
userTypeId: userTypeId.trim(),
|
||||||
|
})
|
||||||
|
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
|
||||||
|
setOpen(false)
|
||||||
|
resetForm()
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
const fe = fieldErrors(err)
|
||||||
|
if (fe) setErrors(fe)
|
||||||
|
toast.error("Could not create user", errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
Create accounts and assign roles. New users receive their credentials by email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button size="lg">
|
||||||
|
<Plus className="size-5" />
|
||||||
|
New User
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="items-center text-center">
|
||||||
|
<DialogTitle>New user</DialogTitle>
|
||||||
|
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field data-invalid={!!errors.username}>
|
||||||
|
<FieldLabel htmlFor="u-username">Username</FieldLabel>
|
||||||
|
<Input id="u-username" value={username} onChange={(e) => setUsername(e.target.value)} aria-invalid={!!errors.username} />
|
||||||
|
<FieldError errors={[errors.username ? { message: errors.username } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.fullName}>
|
||||||
|
<FieldLabel htmlFor="u-fullname">Full name</FieldLabel>
|
||||||
|
<Input id="u-fullname" value={fullName} onChange={(e) => setFullName(e.target.value)} aria-invalid={!!errors.fullName} />
|
||||||
|
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.email}>
|
||||||
|
<FieldLabel htmlFor="u-email">Email</FieldLabel>
|
||||||
|
<Input id="u-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} />
|
||||||
|
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="u-mobile">Mobile number (optional)</FieldLabel>
|
||||||
|
<Input id="u-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="u-nic">NIC (optional)</FieldLabel>
|
||||||
|
<Input id="u-nic" value={nic} onChange={(e) => setNic(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.roleId}>
|
||||||
|
<FieldLabel htmlFor="u-role">Role</FieldLabel>
|
||||||
|
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||||
|
<SelectTrigger id="u-role">
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((r) => (
|
||||||
|
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||||
|
{r.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
<Field data-invalid={!!errors.userTypeId}>
|
||||||
|
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
|
||||||
|
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
|
||||||
|
<SelectTrigger id="u-usertype">
|
||||||
|
<SelectValue placeholder="Select a user type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{userTypes.map((t) => (
|
||||||
|
<SelectItem key={t.userTypeId} value={t.userTypeId}>
|
||||||
|
{t.code ?? t.userTypeId}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError errors={[errors.userTypeId ? { message: errors.userTypeId } : undefined]} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-center gap-3 pt-2">
|
||||||
|
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||||
|
{submitting ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && users === null && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && users !== null && users.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||||
|
<UsersIcon className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No users yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && users !== null && users.length > 0 && (
|
||||||
|
<Table className="text-base">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Username</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Display name</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Role</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||||
|
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<TableRow key={u.userId}>
|
||||||
|
<TableCell className="px-3 py-3.5 font-medium">{u.username}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{u.displayName}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">{u.roleName ?? "—"}</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
|
u.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{u.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3.5">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/settings/users/${u.userId}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||||
|
aria-label={`Edit ${u.username}`}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||||
|
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { Suspense, useState } from "react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
@@ -41,6 +41,14 @@ function GoogleIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|||||||
@@ -16,57 +16,77 @@ import {
|
|||||||
PackageCheck,
|
PackageCheck,
|
||||||
Ruler,
|
Ruler,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tag,
|
Tag,
|
||||||
Truck,
|
Truck,
|
||||||
|
Users,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useAuth } from "@/components/auth/AuthProvider"
|
||||||
|
|
||||||
|
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
|
||||||
|
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
||||||
|
// so role-based filtering (via GET /auth/me's navCodes) can match entries here.
|
||||||
const navItems: {
|
const navItems: {
|
||||||
title: string
|
title: string
|
||||||
|
code: string
|
||||||
href: string
|
href: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
chevron?: boolean
|
chevron?: boolean
|
||||||
children?: { title: string; href: string; icon: LucideIcon }[]
|
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
||||||
}[] = [
|
}[] = [
|
||||||
{ title: "Dashboard", href: "/dashboard", icon: LayoutGrid },
|
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||||
{
|
{
|
||||||
title: "Products",
|
title: "Products",
|
||||||
|
code: "products",
|
||||||
href: "/dashboard/products",
|
href: "/dashboard/products",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
chevron: true,
|
chevron: true,
|
||||||
children: [
|
children: [
|
||||||
{ title: "Item", href: "/dashboard/products", icon: Boxes },
|
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||||
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
|
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||||
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag },
|
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||||
{ title: "Item Type", href: "/dashboard/products/item-types", icon: SwatchBook },
|
{ title: "Item Type", code: "products.item-type", href: "/dashboard/products/item-types", icon: SwatchBook },
|
||||||
{ title: "UOM", href: "/dashboard/products/uoms", icon: Ruler },
|
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||||
{ title: "Configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||||
{ title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
|
{ title: "Procurement", code: "procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
|
||||||
{ title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||||
{ title: "Stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||||
{ title: "Warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||||
{ title: "Orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||||
{ title: "Settings", href: "/dashboard/settings", icon: Settings, chevron: true },
|
{
|
||||||
{ title: "Help", href: "/dashboard/help", icon: HelpCircle },
|
title: "Settings",
|
||||||
|
code: "settings",
|
||||||
|
href: "/dashboard/settings",
|
||||||
|
icon: Settings,
|
||||||
|
chevron: true,
|
||||||
|
children: [
|
||||||
|
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||||
|
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
|
||||||
]
|
]
|
||||||
|
|
||||||
function SidebarContent({
|
function SidebarContent({
|
||||||
|
items,
|
||||||
collapsed,
|
collapsed,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onClose,
|
onClose,
|
||||||
pathname,
|
pathname,
|
||||||
isMobile,
|
isMobile,
|
||||||
}: {
|
}: {
|
||||||
|
items: typeof navItems
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
onCollapse: () => void
|
onCollapse: () => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
@@ -110,7 +130,7 @@ function SidebarContent({
|
|||||||
|
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{navItems.map((item) => {
|
{items.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
|
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
|
||||||
|
|
||||||
@@ -195,6 +215,19 @@ export function AppSidebar() {
|
|||||||
const pathname = usePathname() || "/"
|
const pathname = usePathname() || "/"
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const { navCodes, loading } = useAuth()
|
||||||
|
|
||||||
|
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||||
|
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||||
|
// is visible if its own code is granted, or (for parents) if any child is.
|
||||||
|
const visibleItems = loading
|
||||||
|
? []
|
||||||
|
: navItems
|
||||||
|
.filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
children: item.children?.filter((c) => navCodes.includes(c.code)),
|
||||||
|
}))
|
||||||
|
|
||||||
// Close mobile menu on route change
|
// Close mobile menu on route change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -213,6 +246,7 @@ export function AppSidebar() {
|
|||||||
{/* ── Desktop sidebar ─────────────────────────────── */}
|
{/* ── Desktop sidebar ─────────────────────────────── */}
|
||||||
<div className="my-3 hidden lg:my-4 lg:flex">
|
<div className="my-3 hidden lg:my-4 lg:flex">
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
|
items={visibleItems}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onCollapse={() => setCollapsed((v) => !v)}
|
onCollapse={() => setCollapsed((v) => !v)}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
@@ -247,6 +281,7 @@ export function AppSidebar() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
|
items={visibleItems}
|
||||||
collapsed={false}
|
collapsed={false}
|
||||||
onCollapse={() => {}}
|
onCollapse={() => {}}
|
||||||
onClose={() => setMobileOpen(false)}
|
onClose={() => setMobileOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Authoritative session context: fetches GET /auth/me once per mount and exposes
|
||||||
|
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
|
||||||
|
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
|
||||||
|
// (that cache remains display-only, e.g. for the header's user name).
|
||||||
|
import { createContext, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { authApi } from "@/lib/api/auth"
|
||||||
|
import { MeResponse } from "@/types/rbac"
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
roleCode: string | null
|
||||||
|
roleName: string | null
|
||||||
|
navCodes: string[]
|
||||||
|
/** True until the first `/auth/me` response lands. */
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
roleCode: null,
|
||||||
|
roleName: null,
|
||||||
|
navCodes: [],
|
||||||
|
loading: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [me, setMe] = useState<MeResponse | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
authApi
|
||||||
|
.me()
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setMe(res)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||||
|
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||||
|
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [] })
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
roleCode: me?.roleCode ?? null,
|
||||||
|
roleName: me?.roleName ?? null,
|
||||||
|
navCodes: me?.navCodes ?? [],
|
||||||
|
loading: me === null,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
return useContext(AuthContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Checkbox tree for assigning a role's visible sidebar sections. Leaf nav items
|
||||||
|
// (no children) are toggled directly; parent nav items with children are a
|
||||||
|
// "select all children" convenience toggle — the parent's own visibility is
|
||||||
|
// derived from its children on the frontend (see AppSidebar.tsx's filter), so
|
||||||
|
// only the children need to carry the actual grant for those groups.
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { NavItem } from "@/types/rbac"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tree: NavItem[]
|
||||||
|
selectedNavItemIds: Set<number>
|
||||||
|
selectedSubNavItemIds: Set<number>
|
||||||
|
onChange: (navItemIds: Set<number>, subNavItemIds: Set<number>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavItemIds, onChange }: Props) {
|
||||||
|
function toggleLeaf(navItemId: number) {
|
||||||
|
const next = new Set(selectedNavItemIds)
|
||||||
|
if (next.has(navItemId)) next.delete(navItemId)
|
||||||
|
else next.add(navItemId)
|
||||||
|
onChange(next, selectedSubNavItemIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChild(subNavItemId: number) {
|
||||||
|
const next = new Set(selectedSubNavItemIds)
|
||||||
|
if (next.has(subNavItemId)) next.delete(subNavItemId)
|
||||||
|
else next.add(subNavItemId)
|
||||||
|
onChange(selectedNavItemIds, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllChildren(item: NavItem, checked: boolean) {
|
||||||
|
const next = new Set(selectedSubNavItemIds)
|
||||||
|
for (const child of item.children) {
|
||||||
|
if (checked) next.add(child.subNavItemId)
|
||||||
|
else next.delete(child.subNavItemId)
|
||||||
|
}
|
||||||
|
onChange(selectedNavItemIds, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 rounded-lg border p-4">
|
||||||
|
{tree.map((item) => {
|
||||||
|
if (item.children.length === 0) {
|
||||||
|
return (
|
||||||
|
<label key={item.navItemId} className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedNavItemIds.has(item.navItemId)}
|
||||||
|
onCheckedChange={() => toggleLeaf(item.navItemId)}
|
||||||
|
/>
|
||||||
|
<Label className="cursor-pointer text-base font-medium">{item.label}</Label>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedCount = item.children.filter((c) => selectedSubNavItemIds.has(c.subNavItemId)).length
|
||||||
|
const allChecked = checkedCount === item.children.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.navItemId} className="flex flex-col gap-1 border-t pt-2 first:border-t-0 first:pt-0">
|
||||||
|
<label className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(checked) => toggleAllChildren(item, !!checked)} />
|
||||||
|
<Label className="cursor-pointer text-base font-semibold">
|
||||||
|
{item.label} {checkedCount > 0 && !allChecked && <span className="text-sm font-normal text-muted-foreground">({checkedCount} of {item.children.length})</span>}
|
||||||
|
</Label>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-1 pl-9">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<label key={child.subNavItemId} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedSubNavItemIds.has(child.subNavItemId)}
|
||||||
|
onCheckedChange={() => toggleChild(child.subNavItemId)}
|
||||||
|
/>
|
||||||
|
<Label className="cursor-pointer text-sm">{child.label}</Label>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,8 +2,14 @@
|
|||||||
// delivers the session as httpOnly cookies — there is no token for JS to hold or attach.
|
// delivers the session as httpOnly cookies — there is no token for JS to hold or attach.
|
||||||
import { apiRequest } from "@/lib/api-client"
|
import { apiRequest } from "@/lib/api-client"
|
||||||
import { AuthSession, LoginRequest, RegisterRequest } from "@/types/auth"
|
import { AuthSession, LoginRequest, RegisterRequest } from "@/types/auth"
|
||||||
|
import { MeResponse } from "@/types/rbac"
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
/** Authoritative role + permitted sidebar nav codes for the current session. */
|
||||||
|
me(): Promise<MeResponse> {
|
||||||
|
return apiRequest<MeResponse>("/auth/me")
|
||||||
|
},
|
||||||
|
|
||||||
/** Sets erp_at / erp_rt / XSRF-TOKEN cookies on success. Body carries no tokens. */
|
/** Sets erp_at / erp_rt / XSRF-TOKEN cookies on success. Body carries no tokens. */
|
||||||
login(request: LoginRequest): Promise<AuthSession> {
|
login(request: LoginRequest): Promise<AuthSession> {
|
||||||
return apiRequest<AuthSession>("/auth/login", { method: "POST", body: request })
|
return apiRequest<AuthSession>("/auth/login", { method: "POST", body: request })
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Read-only sidebar nav tree (ERPCore Controllers/NavController.cs).
|
||||||
|
import { apiRequest } from "@/lib/api-client"
|
||||||
|
import { NavItem } from "@/types/rbac"
|
||||||
|
|
||||||
|
export const navApi = {
|
||||||
|
tree(): Promise<NavItem[]> {
|
||||||
|
return apiRequest<NavItem[]>("/nav")
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Role CRUD + permission assignment (ERPCore Controllers/RolesController.cs).
|
||||||
|
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||||
|
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||||
|
import {
|
||||||
|
AssignRolePermissionsRequest,
|
||||||
|
CreateRoleRequest,
|
||||||
|
Role,
|
||||||
|
RolePermissions,
|
||||||
|
UpdateRoleRequest,
|
||||||
|
} from "@/types/rbac"
|
||||||
|
|
||||||
|
export interface ListRolesParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
|
status?: EntityStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rolesApi = {
|
||||||
|
list(params: ListRolesParams = {}): Promise<PagedResponse<Role>> {
|
||||||
|
return apiRequest<PagedResponse<Role>>(`/roles${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
get(roleId: number): Promise<ApiResult<Role>> {
|
||||||
|
return apiRequestWithETag<Role>(`/roles/${roleId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
create(request: CreateRoleRequest): Promise<ApiResult<Role>> {
|
||||||
|
return apiRequestWithETag<Role>("/roles", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
update(roleId: number, request: UpdateRoleRequest, ifMatch: string): Promise<ApiResult<Role>> {
|
||||||
|
return apiRequestWithETag<Role>(`/roles/${roleId}`, { method: "PUT", body: request, ifMatch })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus(roleId: number, status: EntityStatus): Promise<void> {
|
||||||
|
return apiRequest<void>(`/roles/${roleId}/status`, { method: "PATCH", body: { status } })
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(roleId: number): Promise<void> {
|
||||||
|
return apiRequest<void>(`/roles/${roleId}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
|
|
||||||
|
getPermissions(roleId: number): Promise<RolePermissions> {
|
||||||
|
return apiRequest<RolePermissions>(`/roles/${roleId}/permissions`)
|
||||||
|
},
|
||||||
|
|
||||||
|
assignPermissions(roleId: number, request: AssignRolePermissionsRequest): Promise<RolePermissions> {
|
||||||
|
return apiRequest<RolePermissions>(`/roles/${roleId}/permissions`, { method: "PUT", body: request })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// User management (ERPCore Controllers/UsersController.cs). Create orchestrates
|
||||||
|
// account creation in both AuthHex and ERPCore's local shadow table server-side.
|
||||||
|
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||||
|
import { PagedResponse } from "@/types/common"
|
||||||
|
import { CreateUserRequest, ManagedUser, UpdateUserRoleRequest, UserTypeOption } from "@/types/users"
|
||||||
|
|
||||||
|
export interface ListUsersParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
q?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
list(params: ListUsersParams = {}): Promise<PagedResponse<ManagedUser>> {
|
||||||
|
return apiRequest<PagedResponse<ManagedUser>>(`/users${buildQuery(params)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
get(userId: number): Promise<ManagedUser> {
|
||||||
|
return apiRequest<ManagedUser>(`/users/${userId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
create(request: CreateUserRequest): Promise<ManagedUser> {
|
||||||
|
return apiRequest<ManagedUser>("/users", { method: "POST", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRole(userId: number, request: UpdateUserRoleRequest): Promise<ManagedUser> {
|
||||||
|
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
|
||||||
|
},
|
||||||
|
|
||||||
|
userTypes(): Promise<UserTypeOption[]> {
|
||||||
|
return apiRequest<UserTypeOption[]>("/users/user-types")
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Role / Nav / Permission DTOs (mirrors ERPCore's Dtos/Rbac/*.cs exactly).
|
||||||
|
import { EntityStatus } from "@/types/common"
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
roleId: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
isSystemRole: boolean
|
||||||
|
status: EntityStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleRequest {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleRequest {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubNavItem {
|
||||||
|
subNavItemId: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
icon: string | null
|
||||||
|
href: string | null
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
navItemId: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
icon: string | null
|
||||||
|
href: string | null
|
||||||
|
sortOrder: number
|
||||||
|
children: SubNavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePermissions {
|
||||||
|
roleId: number
|
||||||
|
navItemIds: number[]
|
||||||
|
subNavItemIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignRolePermissionsRequest {
|
||||||
|
navItemIds: number[]
|
||||||
|
subNavItemIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
roleCode: string | null
|
||||||
|
roleName: string | null
|
||||||
|
navCodes: string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Managed-user DTOs (mirrors ERPCore's Dtos/Users/UserDtos.cs).
|
||||||
|
import { EntityStatus } from "@/types/common"
|
||||||
|
|
||||||
|
export interface ManagedUser {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
status: EntityStatus
|
||||||
|
roleId: number | null
|
||||||
|
roleCode: string | null
|
||||||
|
roleName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string
|
||||||
|
fullName: string
|
||||||
|
roleId: number
|
||||||
|
userTypeId: string
|
||||||
|
email: string
|
||||||
|
nic?: string | null
|
||||||
|
mobileNumber?: string | null
|
||||||
|
/** Left empty to auto-generate — AuthHex emails it to `email`. */
|
||||||
|
password?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRoleRequest {
|
||||||
|
roleId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AuthHex UserType lookup, for the create-user form's select (no local shadow — read-only). */
|
||||||
|
export interface UserTypeOption {
|
||||||
|
userTypeId: string
|
||||||
|
code: string | null
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
@@ -194,7 +194,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users
|
|||||||
| FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M |
|
| FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M |
|
||||||
|
|
||||||
## B.4 Data Model (summary)
|
## B.4 Data Model (summary)
|
||||||
Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and reserved RBAC (Role, Permission, UserRole, RolePermission).
|
Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and RBAC for sidebar visibility (Role, NavItem, SubNavItem, Permission, RolePermission — see C.8; per-endpoint enforcement still deferred).
|
||||||
|
|
||||||
## B.5 External Interfaces
|
## B.5 External Interfaces
|
||||||
UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-field validation. Hardware: barcode/QR (designed-for). Software: relational DB with transactional integrity + row locking; internal service interfaces/events for Phase-2+ modules.
|
UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-field validation. Hardware: barcode/QR (designed-for). Software: relational DB with transactional integrity + row locking; internal service interfaces/events for Phase-2+ modules.
|
||||||
@@ -348,13 +348,16 @@ AUDIT_LOG(audit_id PK, user_id FK→USER, entity_type, entity_id, action, change
|
|||||||
JOURNAL_ENTRY_STUB(journal_id PK, source_doc_type, source_doc_id, debit_account, credit_account, amount)
|
JOURNAL_ENTRY_STUB(journal_id PK, source_doc_type, source_doc_id, debit_account, credit_account, amount)
|
||||||
```
|
```
|
||||||
|
|
||||||
## C.8 Reserved (RBAC — deferred, schema placeholder only)
|
## C.8 RBAC — sidebar-visibility only (implemented 2026-07-18); per-endpoint enforcement still deferred
|
||||||
```
|
```
|
||||||
ROLE(role_id PK, name)
|
ROLE(role_id PK, auth_role_id [GUID, unique] → AuthHex Role, code, name, is_system_role, status, created_at, updated_at, row_version) -- local shadow/projection of AuthHex's Role, same pattern as USER
|
||||||
PERMISSION(permission_id PK, code)
|
NAV_ITEM(nav_item_id PK, code, label, icon, href, sort_order, status) -- top-level sidebar entry; seeded to match the frontend
|
||||||
USER_ROLE(user_id FK→USER, role_id FK→ROLE)
|
SUB_NAV_ITEM(sub_nav_item_id PK, nav_item_id FK→NAV_ITEM, code, label, icon, href, sort_order, status)
|
||||||
|
PERMISSION(permission_id PK, code, nav_item_id FK→NAV_ITEM [nullable], sub_nav_item_id FK→SUB_NAV_ITEM [nullable]) -- exactly one of the two FKs is set
|
||||||
ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION)
|
ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION)
|
||||||
|
USER(..., role_id FK→ROLE [nullable]) -- added to the existing USER shadow (see C.7)
|
||||||
```
|
```
|
||||||
|
Note: `USER_ROLE` from the original placeholder sketch was dropped — a user has at most one role (`USER.role_id`), matching AuthHex's own `User.RoleId` being a single scalar FK, not a many-to-many.
|
||||||
|
|
||||||
## C.9 Modeling notes (load-bearing)
|
## C.9 Modeling notes (load-bearing)
|
||||||
- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy.
|
- **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy.
|
||||||
@@ -366,7 +369,7 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION)
|
|||||||
- **In-transit + cost-preserving transfer.** `STOCK_TRANSFER` holds `src`/`dest` warehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the **inherited** source cost.
|
- **In-transit + cost-preserving transfer.** `STOCK_TRANSFER` holds `src`/`dest` warehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the **inherited** source cost.
|
||||||
- **FEFO ≠ FIFO.** FIFO governs *costing*; FEFO governs *physical picking* of perishables via `BATCH.expiry_date`.
|
- **FEFO ≠ FIFO.** FIFO governs *costing*; FEFO governs *physical picking* of perishables via `BATCH.expiry_date`.
|
||||||
- **External IdP + shadow user.** Authentication is delegated to **AuthHex** (RS256, issuer `AuthHex`/audience `AuthHexClient`, static public key). `USER` is a **local shadow** of AuthHex identities: `auth_user_id` (GUID from the token's `UserId` claim) is JIT-mapped to the local `int` `user_id` that every `created_by`/`requested_by`/`AUDIT_LOG.user_id`/`STOCK_LEDGER.user_id` FK references — no FK type change. A door policy admits only ERP `UserType`/`Role` holders.
|
- **External IdP + shadow user.** Authentication is delegated to **AuthHex** (RS256, issuer `AuthHex`/audience `AuthHexClient`, static public key). `USER` is a **local shadow** of AuthHex identities: `auth_user_id` (GUID from the token's `UserId` claim) is JIT-mapped to the local `int` `user_id` that every `created_by`/`requested_by`/`AUDIT_LOG.user_id`/`STOCK_LEDGER.user_id` FK references — no FK type change. A door policy admits only ERP `UserType`/`Role` holders.
|
||||||
- **Reserved RBAC.** Role/Permission/UserRole/RolePermission exist for schema-completeness only; only `USER` is live (audit stamp). AuthHex's `RoleCode`/`UserTypeCode` claims drive the door gate today; per-endpoint RBAC is future work.
|
- **RBAC — sidebar visibility, not endpoint enforcement (2026-07-18).** `Role`/`NavItem`/`SubNavItem`/`Permission`/`RolePermission` are now live tables backing Role CRUD (`RolesController`) and a permission-assignment UI. AuthHex remains the source of truth for `Role` identity (Guid PK, referenced by its JWT `RoleId`/`RoleCode` claims); ERPCore's `Role` is a **local shadow synced on write** — `RolesController` calls AuthHex's new `/api/role` functions first, then mirrors the result into the local int-keyed row (`auth_role_id` maps the two), exactly like `USER`/`auth_user_id`. `GET /api/v1/auth/me` resolves the caller's `RoleCode` claim to its local `Role`, joins `RolePermission`, and returns the permitted `NavItem`/`SubNavItem` codes for the frontend to filter its sidebar by. **This is deliberately UI-only**: no endpoint in this API (including the new Role/User/Nav ones) gained an authorization check from this work — AR-01 in `02-SECURITY.md` is unchanged, and per-endpoint RBAC remains future work (Part D there).
|
||||||
- **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs available. Add a table only if alert history is required.
|
- **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs available. Add a table only if alert history is required.
|
||||||
|
|
||||||
## C.10 Entity → implementation mapping
|
## C.10 Entity → implementation mapping
|
||||||
|
|||||||
@@ -111,6 +111,57 @@ Request/response field shapes match AuthHex's own payloads one-for-one (project-
|
|||||||
session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the
|
session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the
|
||||||
`erp_rt` cookie rather than the request body.
|
`erp_rt` cookie rather than the request body.
|
||||||
|
|
||||||
|
### 2.0.1 RBAC — Roles, sidebar nav, Users (added 2026-07-18; sidebar-visibility only, see docs/10 C.8)
|
||||||
|
|
||||||
|
`GET /api/v1/auth/me` — the frontend's authoritative source for the current session's role and permitted sidebar sections
|
||||||
|
(replaces the previous client-only `roleId` cached in localStorage). No payload.
|
||||||
|
**200 OK**
|
||||||
|
```json
|
||||||
|
{ "roleCode": "ADMIN", "roleName": "Administrator", "navCodes": ["dashboard", "products", "products.item", "settings.roles", "..."] }
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/v1/nav` — read-only sidebar tree (`NavItem` + nested `SubNavItem`), seeded to mirror the frontend's hardcoded
|
||||||
|
sidebar (`components/Layouts/AppSidebar.tsx`); used to render the Role permission-assignment checkbox UI. Not admin-editable
|
||||||
|
in this phase.
|
||||||
|
|
||||||
|
**Roles** (`RolesController`) — `Role` is a **local shadow of AuthHex's Role** (same pattern as `USER`/`auth_user_id`,
|
||||||
|
docs/10 C.9): every write below forwards to AuthHex's new `/api/role` functions first, then mirrors the result locally.
|
||||||
|
| Route | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET /roles` | Paged list; `q`, `status` filters. |
|
||||||
|
| `GET /roles/{roleId}` | `ETag` header for `If-Match` on update. |
|
||||||
|
| `POST /roles` | `{ code, name }` → `201`, forwards to AuthHex `createRole`. |
|
||||||
|
| `PUT /roles/{roleId}` | Requires `If-Match`; forwards to AuthHex `updateRole`. |
|
||||||
|
| `PATCH /roles/{roleId}/status` | `{ status }` → `204`. |
|
||||||
|
| `DELETE /roles/{roleId}` | Forwards to AuthHex `deleteRole`; `409 ROLE_IN_USE` if any user still holds it. |
|
||||||
|
| `GET /roles/{roleId}/permissions` | `{ roleId, navItemIds, subNavItemIds }`. |
|
||||||
|
| `PUT /roles/{roleId}/permissions` | Replaces the role's full permission set from `{ navItemIds, subNavItemIds }` — purely local, no AuthHex call. |
|
||||||
|
|
||||||
|
> **`code` is server-accepted but frontend-derived, never hand-typed (2026-07-18).** The Roles UI computes `code` from
|
||||||
|
> `name` (uppercased, non-alphanumeric → `_`) and submits it read-only; the field stays free-form here for API callers,
|
||||||
|
> but no UI lets an operator type or edit it directly, on create or later. The Create Role dialog also now includes the
|
||||||
|
> permission checkbox tree, so `POST /roles` and `PUT /roles/{roleId}/permissions` fire as one user action.
|
||||||
|
>
|
||||||
|
> **Bug fixed (2026-07-18): `POST /roles`/`PUT /roles/{roleId}` 500ing via `AUTH_UPSTREAM_ERROR`.** `IsSystemRole` being
|
||||||
|
> omitted serialized as JSON `null`, and AuthHex's `createRole`/`updateRole` called `JsonElement.GetBoolean()` on it
|
||||||
|
> unconditionally when the key was present — which throws on `null` (unlike `GetString()`, which tolerates it). Fixed on
|
||||||
|
> both sides: AuthHex now checks `ValueKind != JsonValueKind.Null` before reading `isSystemRole`, and ERPCore's
|
||||||
|
> `AuthHexClient` now serializes with `JsonIgnoreCondition.WhenWritingNull` so unset nullable fields are omitted from the
|
||||||
|
> payload entirely rather than sent as explicit nulls — closing this class of bug for any other nullable field sent to AuthHex.
|
||||||
|
|
||||||
|
**Users** (`UsersController`) — manages the local shadow `User` table and orchestrates account creation in AuthHex.
|
||||||
|
| Route | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GET /users` | Paged list, joined with `Role`. |
|
||||||
|
| `GET /users/{userId}` | Single record. |
|
||||||
|
| `POST /users` | Creates the account in **both** backends: calls AuthHex's `registerUser` (which persists the password and emails it to the given `email`), then immediately mirrors the local shadow `User` row (rather than waiting for next-login JIT provisioning). Body: `{ username, fullName, roleId, userTypeId, email, nic?, mobileNumber?, password? }` (`password` empty ⇒ AuthHex auto-generates one). |
|
||||||
|
| `PUT /users/{userId}/role` | `{ roleId }` — local role reassignment only; status/lock changes reuse the existing `/auth/status` and `/auth/lock` proxy endpoints. |
|
||||||
|
| `GET /users/user-types` | Added 2026-07-18. Proxies AuthHex's new `listUserTypes` — `[{ userTypeId, code, description }]`. Populates the Create User form's UserType select so operators pick from a real list instead of typing an AuthHex GUID by hand; defaults to the sole existing type when only one exists. |
|
||||||
|
|
||||||
|
> **Known gap, not fixed (flagged 2026-07-18):** AuthHex's `loginUser` resolves `identifier` against `Email`/`MobileNumber`/`Nic`
|
||||||
|
> only — **not** `Username` (`UserManageRepository.GetUserByIdentifierAndType`). A user created via `POST /users` can log in
|
||||||
|
> with their email but not their username. Out of scope for this change; revisit if/when asked.
|
||||||
|
|
||||||
### 2.1 Items
|
### 2.1 Items
|
||||||
> **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9).
|
> **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9).
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -28,7 +28,9 @@ Principles:
|
|||||||
|
|
||||||
## 2. User flows
|
## 2. User flows
|
||||||
|
|
||||||
The flow below is the end-to-end Phase-1 journey: replenishment need → procurement → receiving → QC hold → stock available → stock operations, with the reorder loop closing back. Colour = functional role (roles are conceptual; RBAC is not enforced in Phase 1).
|
The flow below is the end-to-end Phase-1 journey: replenishment need → procurement → receiving → QC hold → stock available → stock operations, with the reorder loop closing back. Colour = functional role. Roles now drive real sidebar visibility (`GET /auth/me`'s `navCodes`, see docs/10 C.8/docs/11 §2.0.1, admin screens at `/dashboard/settings/roles` and `/dashboard/settings/users`) but **per-endpoint RBAC is still not enforced** — this remains a UI-level filter only.
|
||||||
|
|
||||||
|
> **Roles screen (2026-07-18):** `code` is never typed by an operator — it's derived client-side from `name` (uppercased, non-alphanumeric → `_`) and shown read-only, on both create and edit. The Create Role dialog also includes the permission checkbox tree (`components/auth/RolePermissionTree.tsx`, fed by `GET /nav`), so creating a role and assigning its sidebar permissions is one Save action; the detail page (`/dashboard/settings/roles/[id]`) remains for later edits. The Create User dialog's "User type" is a `<Select>` populated from `GET /users/user-types` (proxying AuthHex's `listUserTypes`), defaulting to the sole existing type — operators never type an AuthHex UserType GUID by hand.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
|
|||||||
Reference in New Issue
Block a user