ui and bug fixers 29/09
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user