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:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -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";
}
+18 -5
View File
@@ -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;
}
+20 -7
View File
@@ -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>();