feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
This commit is contained in:
@@ -39,7 +39,12 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
// ---- UserManager --------------------------------------------------
|
||||
|
||||
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
|
||||
{
|
||||
// Until AuthHex stops requiring a role (its roles are being retired), give it a fixed placeholder so
|
||||
// registration keeps working. The ERP never reads this value; the person's real role is users.RoleId.
|
||||
request.RoleId ??= Guid.TryParse(_config["AuthHex:LegacyRoleId"], out var legacy) ? legacy : null;
|
||||
return CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
|
||||
}
|
||||
|
||||
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
|
||||
@@ -149,23 +154,6 @@ public sealed class AuthHexClient : IAuthHexClient
|
||||
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken 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 --------------------------------------------------------
|
||||
|
||||
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
|
||||
@@ -57,12 +57,4 @@ public interface IAuthHexClient
|
||||
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
|
||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest 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);
|
||||
}
|
||||
|
||||
@@ -22,4 +22,24 @@ public static class AuthHexClaims
|
||||
/// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
|
||||
/// </summary>
|
||||
public const string BranchId = "erp:BranchId";
|
||||
|
||||
/// <summary>
|
||||
/// Local claim: "false" when the deployment has branching switched off (Settings → Branch Configuration), absent or
|
||||
/// "true" otherwise. Lets <see cref="ICurrentBranchContext"/> tell "a branch-less user in a single-branch
|
||||
/// deployment" (sees everything) from "a branch-less user in a multi-branch deployment" (sees no branch's data).
|
||||
/// </summary>
|
||||
public const string BranchingEnabled = "erp:BranchingEnabled";
|
||||
|
||||
// ---- Local claims: the ERP's own authorization identity ---------------------------------------------------
|
||||
// Injected by ShadowUserClaimsTransformation from the ERP's users/roles tables on every request. They are NEVER
|
||||
// read from the token: the role belongs to the ERP, AuthHex only says who the person is. The AuthHex RoleId /
|
||||
// RoleCode claims above are used only by the temporary legacy fallback.
|
||||
|
||||
/// <summary>The effective role's id (the user's role, or the default role). Absent when the person has no usable role.</summary>
|
||||
public const string ErpRoleId = "erp:RoleId";
|
||||
public const string ErpRoleCode = "erp:RoleCode";
|
||||
/// <summary>"true" when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>).</summary>
|
||||
public const string ErpIsAdmin = "erp:IsAdmin";
|
||||
/// <summary>Present when the ERP user is inactive; the <c>ErpAccess</c> policy then refuses the request (403).</summary>
|
||||
public const string ErpBlocked = "erp:Blocked";
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ using ERPCore.Domain.Entities;
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the audit actor from the current HTTP request's principal
|
||||
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/>
|
||||
/// when the request is unauthenticated (e.g. health checks, seeding).
|
||||
/// Resolves the audit actor and the ERP-owned role from the current HTTP request's principal.
|
||||
/// Every value here was put on the principal by <see cref="ShadowUserClaimsTransformation"/> from the ERP's own
|
||||
/// tables — none comes from AuthHex role claims. Falls back to <see cref="SystemActor"/> when the request is
|
||||
/// unauthenticated (e.g. health checks, seeding).
|
||||
/// </summary>
|
||||
public sealed class CurrentUser : ICurrentUser
|
||||
{
|
||||
@@ -32,9 +33,18 @@ public sealed class CurrentUser : ICurrentUser
|
||||
|
||||
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
|
||||
|
||||
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
|
||||
public int? RoleId
|
||||
{
|
||||
get
|
||||
{
|
||||
var raw = _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleId);
|
||||
return int.TryParse(raw, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
|
||||
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleCode);
|
||||
|
||||
public bool IsAdmin => string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpIsAdmin), "true", StringComparison.Ordinal);
|
||||
|
||||
public int? BranchId
|
||||
{
|
||||
@@ -44,4 +54,7 @@ public sealed class CurrentUser : ICurrentUser
|
||||
return int.TryParse(raw, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool BranchingEnabled =>
|
||||
!string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.BranchingEnabled), "false", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace ERPCore.Infra.Auth;
|
||||
public interface ICurrentBranchContext
|
||||
{
|
||||
/// <summary>The branch to scope reads to. Null means "don't filter" — used for
|
||||
/// admins, unauthenticated/system contexts, and any caller with no resolved branch.</summary>
|
||||
/// admins, unauthenticated/system contexts, and (only when the deployment has branching switched off) people with
|
||||
/// no branch. In a branching deployment, a signed-in non-admin with no linked employee gets
|
||||
/// <see cref="CurrentBranchContext.NoBranchId"/>: a branch that matches no data, so they see none (deny by default).</summary>
|
||||
int? BranchId { get; }
|
||||
|
||||
/// <summary>False disables filtering entirely (e.g. background jobs, migrations,
|
||||
@@ -18,17 +20,29 @@ public interface ICurrentBranchContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins and users with
|
||||
/// no resolved branch are never filtered — the same bypass <see cref="Services.Interfaces.IBranchAccessService"/>
|
||||
/// already applies for write-path checks.
|
||||
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins are never filtered, nor is anyone in a
|
||||
/// deployment with branching off. A non-admin without a branch (no linked employee) in a branching deployment is scoped to
|
||||
/// <see cref="NoBranchId"/> — they used to fall through unfiltered and could read every branch's records, while the
|
||||
/// write path (<see cref="Services.Interfaces.IBranchAccessService"/>) already refused them.
|
||||
/// </summary>
|
||||
public sealed class CurrentBranchContext : ICurrentBranchContext
|
||||
{
|
||||
/// <summary>The "branch" of a person who has none in a branching deployment. No row has branch 0, so filters on it match nothing.</summary>
|
||||
public const int NoBranchId = 0;
|
||||
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
|
||||
|
||||
public int? BranchId => _currentUser.IsAdmin ? null : _currentUser.BranchId;
|
||||
public int? BranchId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return null;
|
||||
if (_currentUser.BranchId is { } own) return own;
|
||||
return _currentUser.BranchingEnabled ? NoBranchId : null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsActive => _currentUser.IsAuthenticated && !_currentUser.IsAdmin && _currentUser.BranchId is not null;
|
||||
public bool IsActive => BranchId is not null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the authenticated principal, used purely as the audit actor
|
||||
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub`
|
||||
/// claim — never from the request body (see 00-CORE §4).
|
||||
/// Abstraction over the authenticated principal: the audit actor plus the ERP-owned role. Everything here is
|
||||
/// derived from the ERP's own users/roles tables via ShadowUserClaimsTransformation, keyed by the AuthHex
|
||||
/// identity — never from the request body, and never from AuthHex role claims (see 00-CORE §4).
|
||||
/// </summary>
|
||||
public interface ICurrentUser
|
||||
{
|
||||
@@ -20,13 +20,20 @@ public interface ICurrentUser
|
||||
/// <summary>True when the request carries an authenticated principal.</summary>
|
||||
bool IsAuthenticated { get; }
|
||||
|
||||
/// <summary>The AuthHex <c>RoleCode</c> claim verbatim, or null if absent/unauthenticated.</summary>
|
||||
/// <summary>
|
||||
/// The id of the person's effective ERP role (their own, else the configured default), read from the ERP's users
|
||||
/// table on each request. Null when they have no usable role (none assigned and no default, or the role is
|
||||
/// inactive) — they then have no permissions.
|
||||
/// </summary>
|
||||
int? RoleId { get; }
|
||||
|
||||
/// <summary>The effective role's code, for display and logging. Never use it to make a permission decision.</summary>
|
||||
string? RoleCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim
|
||||
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check;
|
||||
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
|
||||
/// True when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>) — not when its
|
||||
/// name happens to be "Admin". Admins pass every permission check and are the only users that bypass
|
||||
/// branch scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
|
||||
/// </summary>
|
||||
bool IsAdmin { get; }
|
||||
|
||||
@@ -37,4 +44,10 @@ public interface ICurrentUser
|
||||
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
|
||||
/// </summary>
|
||||
int? BranchId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the deployment uses branches at all (Settings → Branch Configuration). When off, every document lives on the
|
||||
/// one default branch and nobody is branch-scoped. Defaults to true — the safe side — for contexts that don't say.
|
||||
/// </summary>
|
||||
bool BranchingEnabled => true;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ public static class JwtAuthExtensions
|
||||
var publicKeyXml = config["Auth:RsaPublicKeyXml"]
|
||||
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
|
||||
var requiredUserType = config["Auth:RequiredUserTypeCode"];
|
||||
var requiredRole = config["Auth:RequiredRoleCode"];
|
||||
|
||||
// AuthHex publishes no JWKS; the RSA public key is configured statically.
|
||||
var rsa = RSA.Create();
|
||||
@@ -85,12 +84,13 @@ public static class JwtAuthExtensions
|
||||
options.AddPolicy(ErpAccessPolicy, policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
// Door gate: only enforce a UserType/Role when configured (AuthHex is a
|
||||
// shared IdP). Empty config = require a valid ERP token only.
|
||||
// Door gate: only enforce a UserType when configured (AuthHex is a shared IdP). Empty config =
|
||||
// require a valid ERP token only. (There is no role door gate any more: roles are the ERP's own,
|
||||
// and are enforced per endpoint by the Nav policies.)
|
||||
if (!string.IsNullOrWhiteSpace(requiredUserType))
|
||||
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
|
||||
if (!string.IsNullOrWhiteSpace(requiredRole))
|
||||
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
|
||||
// An inactive ERP user is refused outright, even while their AuthHex token is still valid.
|
||||
policy.RequireAssertion(ctx => !ctx.User.HasClaim(c => c.Type == AuthHexClaims.ErpBlocked));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ public sealed class NavRequirement : IAuthorizationRequirement
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Admin always passes (mirrors every other Admin-bypasses-scoping rule in this
|
||||
/// codebase, e.g. <c>BranchAccessService</c>); everyone else needs the role permission,
|
||||
/// and — when the policy names one — the specific action within it too.</summary>
|
||||
/// <summary>An administrator (<see cref="ICurrentUser.IsAdmin"/> — the role flag, not a role name) always passes,
|
||||
/// mirroring every other admin-bypasses-scoping rule in this codebase (e.g. <c>BranchAccessService</c>); everyone
|
||||
/// else needs the role's permission and — when the policy names one — the specific action within it too.</summary>
|
||||
public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement>
|
||||
{
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -81,9 +81,9 @@ public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequiremen
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleCode, requirement.NavCode)) return;
|
||||
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleId, requirement.NavCode)) return;
|
||||
|
||||
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleCode, requirement.NavCode, requirement.Action.Value))
|
||||
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleId, requirement.NavCode, requirement.Action.Value))
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,43 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
|
||||
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
|
||||
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
|
||||
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
|
||||
/// <c>int</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
|
||||
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
|
||||
/// Turns an authenticated AuthHex principal into ERPCore's authorization identity.
|
||||
/// <para>
|
||||
/// AuthHex says WHO the person is: its token carries a custom <c>UserId</c> (GUID) and no <c>sub</c>/<c>nameid</c>.
|
||||
/// The ERP decides WHAT they may do, from its own tables. In one query this finds the local shadow
|
||||
/// <see cref="User"/> (keyed by <c>auth_user_id</c>, JIT-provisioned on first sign-in) together with its
|
||||
/// <see cref="Role"/> and branch, then injects:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>NameIdentifier</c> — the local int user id (audit actor);</item>
|
||||
/// <item><see cref="AuthHexClaims.ErpRoleId"/> / <see cref="AuthHexClaims.ErpRoleCode"/> / <see cref="AuthHexClaims.ErpIsAdmin"/> — the effective role;</item>
|
||||
/// <item><see cref="AuthHexClaims.BranchId"/> — the branch of the linked employee;</item>
|
||||
/// <item><see cref="AuthHexClaims.ErpBlocked"/> — when the ERP user is inactive (the door policy then answers 403).</item>
|
||||
/// </list>
|
||||
/// Because the role is read from the database on every request, changing someone's role or a role's permissions
|
||||
/// takes effect on their very next request — no re-login, no stale token.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Effective role</b> = the user's own role if it is active; otherwise nothing. A user with no role gets one
|
||||
/// assigned and saved: during the rollout, the role named by a still-present AuthHex <c>RoleCode</c> claim
|
||||
/// (<c>Auth:LegacyRoleClaimFallback</c>, on by default, to be switched off once every user has a role); else the
|
||||
/// configured default role (<see cref="AccessConfig.DefaultRoleId"/>). With neither, they have no permissions.
|
||||
/// </para>
|
||||
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
|
||||
/// </summary>
|
||||
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||
{
|
||||
private readonly ErpDbContext _db;
|
||||
private readonly bool _legacyRoleFallback;
|
||||
|
||||
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
|
||||
public ShadowUserClaimsTransformation(ErpDbContext db, IConfiguration configuration)
|
||||
{
|
||||
_db = db;
|
||||
// Default ON: the safe choice while some users may still have no local role.
|
||||
_legacyRoleFallback = !bool.TryParse(configuration["Auth:LegacyRoleClaimFallback"], out var enabled) || enabled;
|
||||
}
|
||||
|
||||
private sealed record UserRow(
|
||||
int UserId, EntityStatus Status, int? RoleId, string? RoleCode, EntityStatus? RoleStatus, bool RoleIsAdmin, int? BranchId, bool? BranchingEnabled);
|
||||
|
||||
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
@@ -33,29 +57,63 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||
if (!Guid.TryParse(raw, out var authUserId))
|
||||
return principal; // no mappable identity → CurrentUser falls back to system
|
||||
|
||||
var nic = principal.FindFirstValue(AuthHexClaims.Nic);
|
||||
var localId = await ResolveOrProvisionAsync(authUserId, nic);
|
||||
var row = await LoadAsync(authUserId)
|
||||
?? await ProvisionAsync(authUserId, principal.FindFirstValue(AuthHexClaims.Nic));
|
||||
|
||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
|
||||
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, row.UserId.ToString()));
|
||||
if (row.BranchId is not null)
|
||||
identity.AddClaim(new Claim(AuthHexClaims.BranchId, row.BranchId.Value.ToString()));
|
||||
if (row.BranchingEnabled == false)
|
||||
identity.AddClaim(new Claim(AuthHexClaims.BranchingEnabled, "false"));
|
||||
|
||||
var branchId = await _db.Employees.AsNoTracking()
|
||||
.Where(e => e.UserId == localId)
|
||||
.Select(e => (int?)e.BranchId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (branchId is not null)
|
||||
identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
|
||||
if (row.Status != EntityStatus.Active)
|
||||
{
|
||||
identity.AddClaim(new Claim(AuthHexClaims.ErpBlocked, "1"));
|
||||
return principal;
|
||||
}
|
||||
|
||||
// No role yet? Assign one (and save it) so the Users screen shows what they actually have.
|
||||
if (row.RoleId is null)
|
||||
row = await AssignMissingRoleAsync(row, principal.FindFirstValue(AuthHexClaims.RoleCode));
|
||||
|
||||
// An inactive role grants nothing: the person is treated as having no role until it is reactivated or changed.
|
||||
if (row.RoleId is not null && row.RoleStatus == EntityStatus.Active)
|
||||
{
|
||||
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleId, row.RoleId.Value.ToString()));
|
||||
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleCode, row.RoleCode ?? string.Empty));
|
||||
identity.AddClaim(new Claim(AuthHexClaims.ErpIsAdmin, row.RoleIsAdmin ? "true" : "false"));
|
||||
}
|
||||
|
||||
return principal;
|
||||
}
|
||||
|
||||
private async Task<int> ResolveOrProvisionAsync(Guid authUserId, string? nic)
|
||||
{
|
||||
var existing = await _db.Users.AsNoTracking()
|
||||
/// <summary>The user with its role and branch, in ONE round trip.</summary>
|
||||
private Task<UserRow?> LoadAsync(Guid authUserId) =>
|
||||
_db.Users.AsNoTracking()
|
||||
.Where(u => u.AuthUserId == authUserId)
|
||||
.Select(u => u.UserId)
|
||||
.Select(u => new UserRow(
|
||||
u.UserId, u.Status, u.RoleId,
|
||||
u.Role != null ? u.Role.Code : null,
|
||||
u.Role != null ? (EntityStatus?)u.Role.Status : null,
|
||||
u.Role != null && u.Role.IsAdmin,
|
||||
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
|
||||
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
|
||||
.FirstOrDefaultAsync();
|
||||
if (existing != 0) return existing;
|
||||
|
||||
private Task<UserRow?> LoadAsync(int userId) =>
|
||||
_db.Users.AsNoTracking()
|
||||
.Where(u => u.UserId == userId)
|
||||
.Select(u => new UserRow(
|
||||
u.UserId, u.Status, u.RoleId,
|
||||
u.Role != null ? u.Role.Code : null,
|
||||
u.Role != null ? (EntityStatus?)u.Role.Status : null,
|
||||
u.Role != null && u.Role.IsAdmin,
|
||||
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
|
||||
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
private async Task<UserRow> ProvisionAsync(Guid authUserId, string? nic)
|
||||
{
|
||||
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
|
||||
var user = new User
|
||||
{
|
||||
@@ -69,16 +127,58 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||
{
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
return user.UserId;
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Lost a race (unique auth_user_id) — the row now exists; re-read it.
|
||||
// Lost a race (unique auth_user_id) — the row now exists; re-read it below.
|
||||
_db.Entry(user).State = EntityState.Detached;
|
||||
return await _db.Users.AsNoTracking()
|
||||
.Where(u => u.AuthUserId == authUserId)
|
||||
.Select(u => u.UserId)
|
||||
.FirstAsync();
|
||||
}
|
||||
|
||||
return await LoadAsync(authUserId) ?? throw new InvalidOperationException("Could not provision the ERP user.");
|
||||
}
|
||||
|
||||
private async Task<UserRow> AssignMissingRoleAsync(UserRow row, string? legacyRoleCode)
|
||||
{
|
||||
var role = null as Role;
|
||||
|
||||
// 1) Rollout safety net: the role the (old) AuthHex token still names — what this person could do until now.
|
||||
if (_legacyRoleFallback && !string.IsNullOrWhiteSpace(legacyRoleCode))
|
||||
{
|
||||
var code = legacyRoleCode.Trim().ToLower();
|
||||
role = await _db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Code.ToLower() == code && r.Status == EntityStatus.Active);
|
||||
}
|
||||
|
||||
// 2) The configured default role (least privilege by design; never the admin role).
|
||||
if (role is null)
|
||||
{
|
||||
role = await _db.AccessConfig.AsNoTracking()
|
||||
.Where(c => c.ConfigId == AccessConfig.SingletonId && c.DefaultRole != null && c.DefaultRole.Status == EntityStatus.Active)
|
||||
.Select(c => c.DefaultRole)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
if (role is null) return row; // no role and no default → no permissions
|
||||
|
||||
try
|
||||
{
|
||||
// Only fill a role that is still empty: one an administrator assigned a moment ago is never overwritten.
|
||||
var tracked = await _db.Users.FirstOrDefaultAsync(u => u.UserId == row.UserId);
|
||||
if (tracked is not null && tracked.RoleId is null)
|
||||
{
|
||||
tracked.RoleId = role.RoleId;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else if (tracked?.RoleId is int existing && existing != role.RoleId)
|
||||
{
|
||||
return await LoadAsync(row.UserId) ?? row; // somebody assigned a role meanwhile: use theirs
|
||||
}
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Not fatal: the request still carries the role for this call; the next request retries the save.
|
||||
_db.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
return row with { RoleId = role.RoleId, RoleCode = role.Code, RoleStatus = role.Status, RoleIsAdmin = role.IsAdmin };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
|
||||
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
|
||||
/// actions (docs/02-SECURITY.md §B.2). Bearer-token callers
|
||||
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
|
||||
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
|
||||
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
|
||||
/// issues alongside the session cookies.
|
||||
/// <para>
|
||||
/// Every mutating cookie-authenticated endpoint is covered by <see cref="CsrfProtectionFilter"/> (registered globally),
|
||||
/// so this attribute is only needed on an action that must be checked even when the session cookie is absent
|
||||
/// (AuthController's token-in-body endpoints).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
Enforce(context.HttpContext.Request);
|
||||
await next();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
/// <summary>Throws a 403 <c>CSRF_TOKEN_MISMATCH</c> unless the request is bearer-authenticated or carries a header matching the CSRF cookie.</summary>
|
||||
public static void Enforce(HttpRequest request)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString())) return;
|
||||
|
||||
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
|
||||
@@ -29,6 +36,33 @@ public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
|
||||
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
|
||||
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
|
||||
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registered globally: any state-changing request (POST/PUT/PATCH/DELETE) that is authenticated by the browser's
|
||||
/// session cookie must pass the double-submit check. Not applied to safe methods, to anonymous endpoints (login,
|
||||
/// registration — no session to ride on), to actions that already carry <see cref="ValidateCsrfAttribute"/>, or to
|
||||
/// requests with no session cookie at all (nothing for a forged request to ride on; those fail authentication anyway).
|
||||
/// </summary>
|
||||
public sealed class CsrfProtectionFilter : IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var http = context.HttpContext;
|
||||
var request = http.Request;
|
||||
|
||||
var unsafeMethod = HttpMethods.IsPost(request.Method) || HttpMethods.IsPut(request.Method)
|
||||
|| HttpMethods.IsPatch(request.Method) || HttpMethods.IsDelete(request.Method);
|
||||
var endpoint = http.GetEndpoint();
|
||||
|
||||
if (unsafeMethod
|
||||
&& request.Cookies.ContainsKey(JwtAuthExtensions.AccessTokenCookie)
|
||||
&& endpoint?.Metadata.GetMetadata<IAllowAnonymous>() is null
|
||||
&& endpoint?.Metadata.GetMetadata<ValidateCsrfAttribute>() is null)
|
||||
{
|
||||
ValidateCsrfAttribute.Enforce(request);
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>The singleton access-configuration row; the check constraint makes "singleton" a database guarantee.</summary>
|
||||
public sealed class AccessConfigConfiguration : IEntityTypeConfiguration<AccessConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AccessConfig> builder)
|
||||
{
|
||||
builder.ToTable("access_config", t =>
|
||||
t.HasCheckConstraint("ck_access_config_singleton", $"`ConfigId` = {AccessConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
// If the default role is deleted the setting clears itself rather than blocking the delete.
|
||||
builder.HasOne(c => c.DefaultRole)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.DefaultRoleId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<Appro
|
||||
|
||||
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
|
||||
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
|
||||
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500);
|
||||
builder.Property(c => c.Level2RoleIds).HasMaxLength(500);
|
||||
|
||||
// Dual approval on by default for both documents (plan §8 recommended defaults).
|
||||
builder.HasData(new ApprovalConfig
|
||||
|
||||
@@ -41,7 +41,11 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
|
||||
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
|
||||
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
|
||||
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
|
||||
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 },
|
||||
// Added with the permission-coverage pass (Phase B): these two sidebar sections had no row, so no role could be
|
||||
// granted them and the sidebar had to bypass the check for everybody.
|
||||
new NavItem { NavItemId = 15, Code = "hrm", Label = "Human Resources", Href = "/dashboard/hrm", SortOrder = 15 },
|
||||
new NavItem { NavItemId = 16, Code = "sales-returns", Label = "Sales Returns", Href = "/dashboard/sales/sales-returns", SortOrder = 16 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
/// <summary>Posted opening balances. The unique index on Kind is the once-only guard.</summary>
|
||||
public sealed class OpeningBalanceRunConfiguration : IEntityTypeConfiguration<OpeningBalanceRun>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OpeningBalanceRun> builder)
|
||||
{
|
||||
builder.ToTable("opening_balance_runs");
|
||||
builder.HasKey(x => x.OpeningBalanceRunId);
|
||||
|
||||
builder.Property(x => x.Kind).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.HasIndex(x => x.Kind);
|
||||
|
||||
// The once-only guard: only kinds that may be posted once set this, so a second attempt hits the unique index.
|
||||
builder.Property(x => x.OnceKey).HasMaxLength(20);
|
||||
builder.HasIndex(x => x.OnceKey).IsUnique();
|
||||
|
||||
builder.Property(x => x.CutoverDate).IsRequired();
|
||||
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
|
||||
builder.Property(x => x.TotalAmount).HasPrecision(18, 4);
|
||||
builder.Property(x => x.Currency).IsRequired().HasMaxLength(3);
|
||||
builder.Property(x => x.PostedAt).IsRequired();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OpeningBalanceLineConfiguration : IEntityTypeConfiguration<OpeningBalanceLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OpeningBalanceLine> builder)
|
||||
{
|
||||
builder.ToTable("opening_balance_lines");
|
||||
builder.HasKey(x => x.OpeningBalanceLineId);
|
||||
|
||||
builder.Property(x => x.AccountCode).IsRequired().HasMaxLength(20);
|
||||
builder.Property(x => x.AccountName).IsRequired().HasMaxLength(200);
|
||||
builder.Property(x => x.Amount).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Run)
|
||||
.WithMany(r => r.Lines)
|
||||
.HasForeignKey(x => x.OpeningBalanceRunId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OpeningStockLineConfiguration : IEntityTypeConfiguration<OpeningStockLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OpeningStockLine> builder)
|
||||
{
|
||||
builder.ToTable("opening_stock_lines");
|
||||
builder.HasKey(x => x.OpeningStockLineId);
|
||||
|
||||
builder.Property(x => x.Qty).HasPrecision(18, 4);
|
||||
builder.Property(x => x.UnitCost).HasPrecision(18, 4);
|
||||
builder.Property(x => x.Value).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(x => x.Run)
|
||||
.WithMany(r => r.StockLines)
|
||||
.HasForeignKey(x => x.OpeningBalanceRunId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasIndex(x => new { x.ItemId, x.WarehouseId });
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,20 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 56, Code = "NAV:sales.day-end-report", SubNavItemId = 46 },
|
||||
new Permission { PermissionId = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 },
|
||||
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments", SubNavItemId = 48 },
|
||||
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 }
|
||||
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 },
|
||||
new Permission { PermissionId = 60, Code = "NAV:procurement.po-approvals", SubNavItemId = 50 },
|
||||
new Permission { PermissionId = 61, Code = "NAV:accounts.opening-balances", SubNavItemId = 51 },
|
||||
new Permission { PermissionId = 62, Code = "NAV:hrm", NavItemId = 15 },
|
||||
new Permission { PermissionId = 63, Code = "NAV:sales-returns", NavItemId = 16 },
|
||||
new Permission { PermissionId = 64, Code = "NAV:hrm.employees", SubNavItemId = 52 },
|
||||
new Permission { PermissionId = 65, Code = "NAV:hrm.attendance", SubNavItemId = 53 },
|
||||
new Permission { PermissionId = 66, Code = "NAV:hrm.leave", SubNavItemId = 54 },
|
||||
new Permission { PermissionId = 67, Code = "NAV:hrm.payroll", SubNavItemId = 55 },
|
||||
new Permission { PermissionId = 68, Code = "NAV:hrm.reports", SubNavItemId = 56 },
|
||||
new Permission { PermissionId = 69, Code = "NAV:hrm.settings", SubNavItemId = 57 },
|
||||
new Permission { PermissionId = 70, Code = "NAV:settings.audit-logs", SubNavItemId = 58 },
|
||||
// SubNavItem 24 (Notification Config) never had a permission row, so it could not be granted in Settings → Roles.
|
||||
new Permission { PermissionId = 71, Code = "NAV:settings.notification-config", SubNavItemId = 24 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
/// Configures the singleton procurement-configuration row. The check constraint is what makes
|
||||
/// "singleton" a database guarantee rather than a convention (mirrors GrnConfig).
|
||||
/// </summary>
|
||||
public sealed class ProcurementConfigConfiguration : IEntityTypeConfiguration<ProcurementConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProcurementConfig> builder)
|
||||
{
|
||||
builder.ToTable("procurement_config", t =>
|
||||
t.HasCheckConstraint("ck_procurement_config_singleton", $"`ConfigId` = {ProcurementConfig.SingletonId}"));
|
||||
|
||||
builder.HasKey(c => c.ConfigId);
|
||||
|
||||
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
|
||||
builder.Property(c => c.ConfigId).ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.VendorContactMode)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(10)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(VendorContactMode.None);
|
||||
builder.Property(c => c.VendorAddressEnabled).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.PoTaxEnabled).IsRequired().HasDefaultValue(false);
|
||||
// Default on, and the sentinel is the CLR initializer (true), so an explicit false is still written on insert.
|
||||
builder.Property(c => c.VendorPaymentTermsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.VendorTaxRegEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.UpdatedByUser)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UpdatedBy)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ public sealed class PoLineConfiguration : IEntityTypeConfiguration<PoLine>
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.UnitPrice).HasPrecision(18, 4);
|
||||
builder.Property(l => l.Tax).HasPrecision(9, 4);
|
||||
builder.Property(l => l.TaxAmount).HasPrecision(18, 4).HasDefaultValue(0m);
|
||||
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(l => l.PurchaseOrder)
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
|
||||
builder.ToTable("roles");
|
||||
builder.HasKey(r => r.RoleId);
|
||||
|
||||
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id").IsRequired();
|
||||
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id");
|
||||
builder.HasIndex(r => r.AuthRoleId).IsUnique();
|
||||
|
||||
builder.Property(r => r.Code).IsRequired().HasMaxLength(50);
|
||||
@@ -20,6 +20,7 @@ public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
|
||||
|
||||
builder.Property(r => r.Name).IsRequired().HasMaxLength(200);
|
||||
builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(r => r.IsAdmin).IsRequired().HasDefaultValue(false);
|
||||
|
||||
builder.Property(r => r.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
|
||||
@@ -27,6 +27,13 @@ public sealed class SalesConfigConfiguration : IEntityTypeConfiguration<SalesCon
|
||||
builder.Property(c => c.AllowDiscounts).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.AllowOverStockSale).IsRequired().HasDefaultValue(false);
|
||||
builder.Property(c => c.AllowWarehouseSelection).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.CustomerPhoneEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.CustomerEmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.CustomerAddressEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.CustomerCityEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.CustomerCountryEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.CustomerCreditLimitEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
builder.Property(c => c.CustomerCreditDaysEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
|
||||
|
||||
builder.HasOne(c => c.DefaultWarehouse)
|
||||
.WithMany()
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 50, NavItemId = 4, Code = "procurement.po-approvals", Label = "PO Approval Queue", Href = "/dashboard/procurement/purchase-orders/approvals", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 28, NavItemId = 4, Code = "procurement.purchase-invoices", Label = "Purchase Invoices", Href = "/dashboard/procurement/purchase-invoices", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
|
||||
@@ -73,6 +74,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 44, NavItemId = 12, Code = "accounts.expenses", Label = "Expenses", Href = "/dashboard/accounts/expenses", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 45, NavItemId = 12, Code = "accounts.expense-setup", Label = "Expense Setup", Href = "/dashboard/accounts/expense-setup", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 47, NavItemId = 12, Code = "accounts.bank-deposits", Label = "Bank Deposits", Href = "/dashboard/accounts/bank-deposits", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 51, NavItemId = 12, Code = "accounts.opening-balances", Label = "Opening Balances", Href = "/dashboard/accounts/opening-balances", SortOrder = 7 },
|
||||
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
|
||||
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
|
||||
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
|
||||
@@ -91,7 +93,17 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
|
||||
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
|
||||
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 },
|
||||
// Human Resources (NavItemId 15) children — mirror the sidebar order (AppSidebar.tsx). hrm.payroll also guards
|
||||
// salary structures, loans, bank details, payslips and the pay reports.
|
||||
new SubNavItem { SubNavItemId = 52, NavItemId = 15, Code = "hrm.employees", Label = "Employees", Href = "/dashboard/hrm/employees", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 53, NavItemId = 15, Code = "hrm.attendance", Label = "Attendance", Href = "/dashboard/hrm/attendance", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 54, NavItemId = 15, Code = "hrm.leave", Label = "Leave", Href = "/dashboard/hrm/leave", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 55, NavItemId = 15, Code = "hrm.payroll", Label = "Payroll", Href = "/dashboard/hrm/payroll", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 56, NavItemId = 15, Code = "hrm.reports", Label = "Reports", Href = "/dashboard/hrm/reports", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 57, NavItemId = 15, Code = "hrm.settings", Label = "HR Settings", Href = "/dashboard/hrm/settings", SortOrder = 6 },
|
||||
// Who changed what (including salary changes) is its own Settings entry, not part of "Settings" at large.
|
||||
new SubNavItem { SubNavItemId = 58, NavItemId = 9, Code = "settings.audit-logs", Label = "Audit Log", Href = "/dashboard/settings/audit-logs", SortOrder = 5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
|
||||
builder.Property(v => v.Name).IsRequired().HasMaxLength(200);
|
||||
builder.Property(v => v.Terms).HasMaxLength(50);
|
||||
builder.Property(v => v.TaxReg).HasMaxLength(50);
|
||||
builder.Property(v => v.Mobile).HasMaxLength(20);
|
||||
builder.Property(v => v.Email).HasMaxLength(200);
|
||||
builder.Property(v => v.Address).HasMaxLength(500);
|
||||
builder.Property(v => v.Currency).IsRequired().HasMaxLength(3).HasDefaultValue("LKR");
|
||||
|
||||
builder.Property(v => v.Status)
|
||||
|
||||
@@ -61,12 +61,67 @@ public static class DataSeeder
|
||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||
dirty |= await SeedProcurementConfigAsync(db, ct);
|
||||
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||
|
||||
if (dirty) await db.SaveChangesAsync(ct);
|
||||
|
||||
// After the rest: needs the permission rows (HasData) and saves in steps, because the access config
|
||||
// points at roles that must have real generated ids first.
|
||||
await SeedAccessAsync(db, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The roles every deployment must have, and the access configuration (idempotent):
|
||||
/// <list type="bullet">
|
||||
/// <item>an <b>Administrator</b> role flagged <see cref="Role.IsAdmin"/> — an existing role whose code is "admin" is
|
||||
/// flagged rather than duplicated; a fresh database gets one with every permission, so it is never lock-out empty;</item>
|
||||
/// <item>a <b>No access</b> role with no permissions at all — the default for new sign-ins, so a mistake can never grant access;</item>
|
||||
/// <item>the singleton access config, pointing its default role at "No access" the first time only (an admin's later choice is never overwritten).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static async Task SeedAccessAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var admin = await db.Roles.FirstOrDefaultAsync(r => r.IsAdmin, ct)
|
||||
?? await db.Roles.FirstOrDefaultAsync(r => r.Code.ToLower() == "admin", ct);
|
||||
if (admin is null)
|
||||
{
|
||||
admin = new Role { Code = "Admin", Name = "Administrator", IsSystemRole = true, IsAdmin = true, Status = EntityStatus.Active, CreatedAt = now };
|
||||
db.Roles.Add(admin);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
foreach (var permissionId in await db.Permissions.Select(p => p.PermissionId).ToListAsync(ct))
|
||||
db.RolePermissions.Add(new RolePermission { RoleId = admin.RoleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
else if (!admin.IsAdmin)
|
||||
{
|
||||
admin.IsAdmin = true;
|
||||
admin.UpdatedAt = now;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
var noAccess = await db.Roles.FirstOrDefaultAsync(r => r.Code == DefaultNoAccessRoleCode, ct);
|
||||
if (noAccess is null)
|
||||
{
|
||||
noAccess = new Role { Code = DefaultNoAccessRoleCode, Name = "No access", IsSystemRole = true, Status = EntityStatus.Active, CreatedAt = now };
|
||||
db.Roles.Add(noAccess);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
if (!await db.AccessConfig.AnyAsync(c => c.ConfigId == AccessConfig.SingletonId, ct))
|
||||
{
|
||||
db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = noAccess.RoleId });
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Code of the seeded least-privilege role new sign-ins start with.</summary>
|
||||
public const string DefaultNoAccessRoleCode = "NoAccess";
|
||||
|
||||
/// <summary>
|
||||
/// Creates the deployment's default branch (<see cref="Branch.DefaultCode"/>) if absent.
|
||||
/// </summary>
|
||||
@@ -201,6 +256,16 @@ public static class DataSeeder
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Seeds the procurement configuration singleton — vendor contact/address and PO tax all off,
|
||||
/// until an admin opts in from Settings → Configuration → Procurement Configuration.</summary>
|
||||
private static async Task<bool> SeedProcurementConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.ProcurementConfig.AnyAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct)) return false;
|
||||
|
||||
db.ProcurementConfig.Add(new ProcurementConfig { ConfigId = ProcurementConfig.SingletonId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
|
||||
/// values once so an existing deployment keeps the mapping it has been running with.
|
||||
|
||||
@@ -48,6 +48,9 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
|
||||
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
|
||||
public DbSet<ProcurementConfig> ProcurementConfig => Set<ProcurementConfig>();
|
||||
/// <summary>Singleton row: the default role for people who have none.</summary>
|
||||
public DbSet<AccessConfig> AccessConfig => Set<AccessConfig>();
|
||||
/// <summary>Singleton row mapping each accounting role to a GL account code.</summary>
|
||||
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
|
||||
/// <summary>Singleton row for notification delivery preferences.</summary>
|
||||
@@ -98,6 +101,9 @@ public class ErpDbContext : DbContext
|
||||
|
||||
// --- Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md) ---
|
||||
public DbSet<BankDeposit> BankDeposits => Set<BankDeposit>();
|
||||
public DbSet<OpeningBalanceRun> OpeningBalanceRuns => Set<OpeningBalanceRun>();
|
||||
public DbSet<OpeningBalanceLine> OpeningBalanceLines => Set<OpeningBalanceLine>();
|
||||
public DbSet<OpeningStockLine> OpeningStockLines => Set<OpeningStockLine>();
|
||||
|
||||
// --- Dual approval: stock & price adjustments (docs/DUAL_APPROVAL_PLAN.md) ---
|
||||
public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>();
|
||||
|
||||
Reference in New Issue
Block a user