ui and bug fixers 29/09

This commit is contained in:
2026-09-29 14:49:11 +05:30
parent d24072e20a
commit b2646c6cb3
85 changed files with 30050 additions and 750 deletions
@@ -1,25 +1,54 @@
using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces;
using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthRecoveryService"/>
public sealed class AuthRecoveryService : IAuthRecoveryService
{
/// <summary>Longer than any AuthHex recovery OTP lives; only needs to outlast the flow.</summary>
private static readonly TimeSpan RecoveryIdentifierTtl = TimeSpan.FromMinutes(30);
private readonly IAuthHexClient _authHex;
private readonly IUserManagementService _userManagement;
private readonly IMemoryCache _cache;
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
public AuthRecoveryService(IAuthHexClient authHex, IUserManagementService userManagement, IMemoryCache cache)
{
_authHex = authHex;
_userManagement = userManagement;
_cache = cache;
}
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
=> _authHex.ForgotPasswordAsync(request, ct);
public async Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
{
var response = await _authHex.ForgotPasswordAsync(request, ct);
// The reset call only carries the reference number — remember who it was for, so a
// successful reset can also release a pending first-login forced password change.
if (!string.IsNullOrEmpty(response.ReferenceNumber))
_cache.Set(CacheKey(response.ReferenceNumber), request.Identifier.Trim(), RecoveryIdentifierTtl);
return response;
}
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordAsync(request, ct);
public async Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
{
await _authHex.ResetPasswordAsync(request, ct);
var key = CacheKey(request.ReferenceNumber);
if (_cache.TryGetValue(key, out string? identifier) && !string.IsNullOrEmpty(identifier))
{
_cache.Remove(key);
await _userManagement.ClearMustChangePasswordByIdentifierAsync(identifier, ct);
}
}
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
private static string CacheKey(string referenceNumber) => $"auth-recovery:{referenceNumber}";
}
@@ -54,6 +54,8 @@ public sealed class BranchConfigService : IBranchConfigService
config.BranchingEnabled = request.BranchingEnabled!.Value;
config.WarehouseBranchingEnabled = request.WarehouseBranchingEnabled!.Value;
if (request.PurchaseOrderBranchingEnabled is bool poBranching)
config.PurchaseOrderBranchingEnabled = poBranching;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -110,6 +112,25 @@ public sealed class BranchConfigService : IBranchConfigService
return await ResolveDefaultBranchIdAsync(ct);
}
public async Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
return (config?.BranchingEnabled ?? true) && (config?.PurchaseOrderBranchingEnabled ?? true);
}
public async Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
{
if (await IsPurchaseOrderBranchingEnabledAsync(ct))
{
if (requestedBranchId <= 0)
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
return requestedBranchId;
}
return await ResolveDefaultBranchIdAsync(ct);
}
// Branching (or warehouse branching specifically) is off — ignore whatever was posted and
// use the deployment's one default branch, creating it on first use (same placeholder
// MakeEmployeeBranchRequired/AddWarehouseBranch backfill onto for pre-existing rows).
@@ -125,5 +146,6 @@ public sealed class BranchConfigService : IBranchConfigService
return branch.BranchId;
}
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.WarehouseBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
private static BranchConfigDto Map(BranchConfig c) => new(
c.BranchingEnabled, c.WarehouseBranchingEnabled, c.PurchaseOrderBranchingEnabled, c.UpdatedAt, c.UpdatedBy);
}
+3 -1
View File
@@ -84,6 +84,8 @@ public sealed class DashboardService : IDashboardService
// not yet added) — these three stay global regardless of the selected branch.
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
var fullyReceivedPOs = await _pos.Query().AsNoTracking()
.CountAsync(p => p.Status == PurchaseOrderStatus.FullyReceived, ct);
var openRequisitions = await _requisitions.Query().AsNoTracking()
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
@@ -105,7 +107,7 @@ public sealed class DashboardService : IDashboardService
return new DashboardStatsDto(
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingApprovalPOs, fullyReceivedPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
pendingLeaveRequests, overdueSalesInvoices);
}
}
@@ -29,6 +29,14 @@ public interface IBranchConfigService
/// <see cref="IsWarehouseBranchingEnabledAsync"/> instead of the global flag.</summary>
Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>True when Purchase Orders are divided by branch. Never true when
/// <see cref="IsBranchingEnabledAsync"/> is false.</summary>
Task<bool> IsPurchaseOrderBranchingEnabledAsync(CancellationToken ct = default);
/// <summary>Same as <see cref="ResolveBranchIdAsync"/> but gated on
/// <see cref="IsPurchaseOrderBranchingEnabledAsync"/> — off puts every PO on the default branch.</summary>
Task<int> ResolvePurchaseOrderBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
/// <summary>The deployment's single default branch ("BR-000"/"Main Branch"), created on
/// first use if it doesn't exist yet. Used as a fallback branch for document types that
/// don't yet carry an explicit branch (docs/BRANCH_ISOLATION_PLAN.md).</summary>
@@ -27,6 +27,12 @@ public interface IUserManagementService
/// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>Clears the first-login forced password change once the user has set their own password.</summary>
Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default);
/// <summary>Same, for a forgot-password reset — resolves the user by email or username.</summary>
Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
@@ -88,7 +88,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
{
await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var branchId = await _branchConfig.ResolvePurchaseOrderBranchIdAsync(request.BranchId, ct);
await EnsureLinesInBranchAsync(branchId, request.Lines.Select(l => l.WarehouseId), ct);
var actor = _currentUser.AuditUserId;
@@ -305,9 +305,16 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
}
/// <summary>A PO is constrained to one branch (docs/BRANCH_ISOLATION_PLAN.md — Open
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.</summary>
/// Question #1, decided as (a)): every line's warehouse must belong to the PO's branch.
/// Skipped unless both purchase orders AND warehouses are divided by branch: with PO
/// branching off the PO sits on the default branch, and with warehouse branching off every
/// warehouse is parked on the default branch too (shared by all branches) — either way a
/// branch match is meaningless and would reject every line.</summary>
private async Task EnsureLinesInBranchAsync(int branchId, IEnumerable<int> warehouseIds, CancellationToken ct)
{
if (!await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)) return;
if (!await _branchConfig.IsWarehouseBranchingEnabledAsync(ct)) return;
var ids = warehouseIds.Distinct().ToList();
var mismatched = await _warehouses.Query().AsNoTracking()
.Where(w => ids.Contains(w.WarehouseId) && w.BranchId != branchId)
+19 -1
View File
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig;
@@ -29,12 +30,13 @@ public sealed class RoleService : IRoleService
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_roles = roles;
_rolePermissions = rolePermissions;
_permissions = permissions;
_employees = employees;
_users = users;
_currentUser = currentUser;
_authHex = authHex;
_branchConfig = branchConfig;
@@ -202,6 +204,22 @@ public sealed class RoleService : IRoleService
}
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
{
var me = await BuildMeAsync(roleCode, ct) with
{
PurchaseOrderBranchingEnabled = await _branchConfig.IsPurchaseOrderBranchingEnabledAsync(ct)
};
if (!_currentUser.IsAuthenticated) return me;
var mustChangePassword = await _users.Query().AsNoTracking()
.Where(u => u.UserId == _currentUser.AuditUserId)
.Select(u => u.MustChangePassword)
.FirstOrDefaultAsync(ct);
// Local user id lets the frontend spot records the caller created (e.g. self-approval of a PO).
return me with { MustChangePassword = mustChangePassword, UserId = _currentUser.AuditUserId };
}
private async Task<MeResponseDto> BuildMeAsync(string? roleCode, CancellationToken ct)
{
// The caller's branch, via their optional Employee link — independent of role,
// since even an Admin's own "home" branch may be worth surfacing later. Null
@@ -124,7 +124,9 @@ public sealed class UserManagementService : IUserManagementService
DisplayName = request.FullName.Trim(),
Email = request.Email.Trim(),
RoleId = role.RoleId,
Status = EntityStatus.Active
Status = EntityStatus.Active,
// The admin chose (or AuthHex generated) this password — the owner must replace it on first login.
MustChangePassword = true
};
await _users.AddAsync(user, ct);
@@ -183,6 +185,27 @@ public sealed class UserManagementService : IUserManagementService
return Map(user);
}
public async Task ClearMustChangePasswordAsync(int userId, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct);
if (user is null || !user.MustChangePassword) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
public async Task ClearMustChangePasswordByIdentifierAsync(string identifier, CancellationToken ct = default)
{
var term = identifier.Trim();
var user = await _users.Query()
.FirstOrDefaultAsync(u => u.MustChangePassword
&& ((u.Email != null && EF.Functions.ILike(u.Email, term)) || u.Username == term), ct);
if (user is null) return;
user.MustChangePassword = false;
await _uow.SaveChangesAsync(ct);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{
var userTypes = await _authHex.ListUserTypesAsync(ct);
+7 -1
View File
@@ -62,6 +62,8 @@ public sealed class VendorService : IVendorService
Name = request.Name.Trim(),
Terms = request.Terms,
TaxReg = request.TaxReg,
Address = Normalize(request.Address),
Phone = Normalize(request.Phone),
Currency = request.Currency.Trim().ToUpperInvariant(),
Status = EntityStatus.Active,
BranchId = branchId,
@@ -94,6 +96,8 @@ public sealed class VendorService : IVendorService
vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg;
vendor.Address = Normalize(request.Address);
vendor.Phone = Normalize(request.Phone);
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
vendor.BranchId = branchId;
vendor.UpdatedAt = DateTime.UtcNow;
@@ -120,6 +124,8 @@ public sealed class VendorService : IVendorService
await _uow.SaveChangesAsync(ct);
}
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Address, v.Phone, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
}