feat: implement user role status toggle and reset password functionality

- Added toggle functionality for user roles (activate/deactivate) in roles and users pages.
- Introduced a reset password page that validates and updates user passwords via a reset link.
- Enhanced user detail page with edit functionality for user information (display name, email, mobile number).
- Implemented sending reset link feature for users, with appropriate error handling and success notifications.
- Updated API endpoints for user management to support new functionalities.
- Improved UI components for better user experience during status updates and password changes.
This commit is contained in:
Dhananjaya99
2026-10-15 10:54:28 +05:30
parent 5ec3edc0c6
commit 61330f3927
26 changed files with 817 additions and 141 deletions
@@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
[HttpPut("{**path}")] [HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct); public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
[HttpPatch("{**path}")]
public Task<IActionResult> Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct);
[HttpDelete("{**path}")]
public Task<IActionResult> Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct) private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{ {
var body = method == HttpMethod.Get ? null : Request.Body; var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct); var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult return new ContentResult
{ {
+15 -1
View File
@@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct) public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct)); => Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary> /// <summary>Edits DisplayName/Email/MobileNumber on both backends — see <see cref="IUserManagementService.UpdateAsync"/>.</summary>
[HttpPut("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct)
=> Ok(await _users.UpdateAsync(userId, request, ct));
/// <summary>Revokes/restores standing in both ERPCore and AuthHex — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")] [HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct) public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct)); => Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Emails the user a 15-minute password-reset link — see <see cref="IUserManagementService.SendResetLinkAsync"/>.</summary>
[HttpPost("{userId:int}/send-reset-link")]
[ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SendResetLinkResponse>> SendResetLink(int userId, CancellationToken ct)
=> Ok(await _users.SendResetLinkAsync(userId, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions" /// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary> /// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")] [HttpGet("{userId:int}/login-devices")]
@@ -44,6 +44,15 @@ public sealed class DeletedAuthHexUser
public string? Email { get; set; } public string? Email { get; set; }
} }
/// <summary>Admin-only profile edit — any field left null is left unchanged upstream.</summary>
public sealed class AdminUpdateUserRequest
{
public string? FullName { get; set; }
public string? UserName { get; set; }
public string? Email { get; set; }
public string? MobileNumber { get; set; }
}
public sealed class RegisterRequest public sealed class RegisterRequest
{ {
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary> /// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
+15
View File
@@ -67,5 +67,20 @@ public sealed class UpdateUserStatusRequest
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
} }
/// <summary>
/// Edits an existing user's identity fields in both backends: the local shadow row
/// (DisplayName/Email) and, when the user has a linked AuthHex identity, the AuthHex
/// account itself via the admin-only AdminUpdateUser function. A user created before
/// AuthHex linking (AuthUserId null) only updates locally.
/// </summary>
public sealed class UpdateManagedUserRequest
{
[Required, StringLength(200)] public string DisplayName { get; set; } = string.Empty;
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
public string? MobileNumber { get; set; }
}
public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary> /// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description); public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
@@ -93,16 +93,35 @@ public sealed class AuthHexClient : IAuthHexClient
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct); => CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct) public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
{ => CallAsync<DeleteAuthHexUserResult>(
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
return CallAsync<DeleteAuthHexUserResult>(
"user", "deleteUser", "user", "deleteUser",
new { email, mobileNumber, nic, userName = username }, new { email, mobileNumber, nic, userName = username },
null, ct, null, ct,
extraHeaders: new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey }); extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminUpdateUser",
new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber },
null, ct,
extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminSetUserStatus",
new { userId, isActive },
null, ct,
extraHeaders: AdminHeaders());
/// <summary>Shared guard for every admin-only AuthHex function — throws up front rather
/// than leaving a misconfigured deployment to fail opaquely on the network call.</summary>
private Dictionary<string, string> AdminHeaders()
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call this admin function.", 500);
return new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey };
} }
// ---- RecoveryManager ------------------------------------------------ // ---- RecoveryManager ------------------------------------------------
@@ -38,6 +38,15 @@ public interface IAuthHexClient
/// </summary> /// </summary>
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct); Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
/// <summary>Admin-only: updates another user's identity fields (not self-service —
/// <see cref="UpdateUserAsync"/> only ever touches the caller's own account).
/// Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct);
/// <summary>Admin-only: activates/deactivates another user's AuthHex identity and, when
/// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct);
// RecoveryManager (POST /api/recovery) // RecoveryManager (POST /api/recovery)
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct); Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct); Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
@@ -118,6 +118,12 @@ public sealed class BranchService : IBranchService
var branch = await _branches.GetByIdAsync(branchId, ct) var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found."); ?? throw new NotFoundException($"Branch {branchId} was not found.");
if (status == EntityStatus.Inactive && branch.Code == Branch.DefaultCode)
throw new DomainException(
ErrorCodes.DefaultBranchProtected,
$"'{branch.Name}' is the deployment's default branch and cannot be deactivated.",
409);
branch.Status = status; branch.Status = status;
branch.UpdatedAt = DateTime.UtcNow; branch.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
@@ -128,6 +134,12 @@ public sealed class BranchService : IBranchService
var branch = await _branches.GetByIdAsync(branchId, ct) var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found."); ?? throw new NotFoundException($"Branch {branchId} was not found.");
if (branch.Code == Branch.DefaultCode)
throw new DomainException(
ErrorCodes.DefaultBranchProtected,
$"'{branch.Name}' is the deployment's default branch and cannot be deleted.",
409);
_branches.Remove(branch); _branches.Remove(branch);
try try
{ {
@@ -17,16 +17,27 @@ public interface IUserManagementService
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default); Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default); Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>
/// Edits DisplayName/Email (and MobileNumber upstream) on both the local shadow row and,
/// when linked, the AuthHex identity itself (via the admin-only AdminUpdateUser function).
/// </summary>
Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default);
/// <summary> /// <summary>
/// Flips the local shadow row's status (Active/Inactive) — this is what /// Flips the local shadow row's status (Active/Inactive) — this is what
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no /// BranchAccessService and the rest of ERPCore gate on — and, when the user has a
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus /// linked AuthHex identity, also flips it there via the admin-only AdminSetUserStatus
/// is self-service, acting on whoever's bearer token is passed), so the /// function (deactivating additionally invalidates their live AuthHex sessions), so a
/// credential itself stays live upstream; this only revokes the account's /// user shown Inactive here can no longer log in at all, not just inside ERPCore.
/// standing inside ERPCore.
/// </summary> /// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default); Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>
/// Emails the user a time-limited (15 min) password-reset link via AuthHex's existing
/// self-service forgot-password flow, triggered on their behalf by an admin.
/// </summary>
Task<SendResetLinkResponse> SendResetLinkAsync(int userId, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary> /// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default); Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
+24 -1
View File
@@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService
private readonly IRepository<RolePermission> _rolePermissions; private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions; private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees; private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex; private readonly IAuthHexClient _authHex;
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
@@ -29,12 +30,14 @@ public sealed class RoleService : IRoleService
public RoleService( public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions, 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; _roles = roles;
_rolePermissions = rolePermissions; _rolePermissions = rolePermissions;
_permissions = permissions; _permissions = permissions;
_employees = employees; _employees = employees;
_users = users;
_currentUser = currentUser; _currentUser = currentUser;
_authHex = authHex; _authHex = authHex;
_branchConfig = branchConfig; _branchConfig = branchConfig;
@@ -128,6 +131,9 @@ public sealed class RoleService : IRoleService
var role = await _roles.GetByIdAsync(roleId, ct) var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found."); ?? throw new NotFoundException($"Role {roleId} was not found.");
if (status == EntityStatus.Inactive)
await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct);
role.Status = status; role.Status = status;
role.UpdatedAt = DateTime.UtcNow; role.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
@@ -138,6 +144,8 @@ public sealed class RoleService : IRoleService
var role = await _roles.GetByIdAsync(roleId, ct) var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found."); ?? throw new NotFoundException($"Role {roleId} was not found.");
await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct);
try try
{ {
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct); await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
@@ -151,6 +159,21 @@ public sealed class RoleService : IRoleService
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
} }
/// <summary>
/// A role still assigned to at least one local (ERPCore shadow) user cannot be
/// deactivated or deleted — that user would be left pointing at a role they can no
/// longer effectively hold. Reassign them to a different role first.
/// </summary>
private async Task EnsureNotAssignedToAnyUserAsync(int roleId, string action, CancellationToken ct)
{
var assignedUserCount = await _users.Query().AsNoTracking().CountAsync(u => u.RoleId == roleId, ct);
if (assignedUserCount > 0)
throw new DomainException(
ErrorCodes.RoleInUse,
$"This role is assigned to {assignedUserCount} user(s) and cannot be {action}. Reassign them to a different role first.",
409);
}
public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default) public async Task<RolePermissionsDto> GetPermissionsAsync(int roleId, CancellationToken ct = default)
{ {
_ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found.");
@@ -173,17 +173,65 @@ public sealed class UserManagementService : IUserManagementService
return Map(user); return Map(user);
} }
public async Task<ManagedUserDto> UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default)
{
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
var displayName = request.DisplayName.Trim();
var email = request.Email.Trim();
// Source of truth first — if AuthHex rejects this (e.g. email already taken by
// another identity), the local row must not drift out of sync with it.
if (user.AuthUserId is Guid authUserId)
{
await _authHex.AdminUpdateUserAsync(authUserId, new AdminUpdateUserRequest
{
FullName = displayName,
Email = email,
MobileNumber = request.MobileNumber
}, ct);
}
user.DisplayName = displayName;
user.Email = email;
await _uow.SaveChangesAsync(ct);
return Map(user);
}
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default) public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
{ {
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct) var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found."); ?? throw new NotFoundException($"User {userId} was not found.");
// The local flag alone is cosmetic — without this, a user shown "Inactive" here
// could still log in directly against AuthHex.
if (user.AuthUserId is Guid authUserId)
await _authHex.AdminSetUserStatusAsync(authUserId, status == EntityStatus.Active, ct);
user.Status = status; user.Status = status;
await _uow.SaveChangesAsync(ct); await _uow.SaveChangesAsync(ct);
return Map(user); return Map(user);
} }
public async Task<SendResetLinkResponse> SendResetLinkAsync(int userId, CancellationToken ct = default)
{
var user = await _users.GetByIdAsync(userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
if (string.IsNullOrWhiteSpace(user.Email))
throw new DomainException(ErrorCodes.Validation, "This user has no email address on file.", 422);
var result = await _authHex.ForgotPasswordAsync(new ForgotPasswordRequest
{
Identifier = user.Email,
UseResetLink = true
}, ct);
return new SendResetLinkResponse(result.ExpiresAt);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default) public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{ {
var userTypes = await _authHex.ListUserTypesAsync(ct); var userTypes = await _authHex.ListUserTypesAsync(ct);
@@ -56,6 +56,7 @@ public static class ErrorCodes
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY"; public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
public const string DefaultBranchProtected = "DEFAULT_BRANCH_PROTECTED";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4) // Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE"; public const string TemplateInUse = "TEMPLATE_IN_USE";
+2 -1
View File
@@ -9,7 +9,8 @@
"DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com" "DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com"
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:5011" "BaseUrl": "http://localhost:5011",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
}, },
"GeneralLedgerService": { "GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/" "BaseUrl": "https://localhost:7024/api/v1/"
+2 -1
View File
@@ -3,7 +3,8 @@
"SigningKey": "" "SigningKey": ""
}, },
"AuthHex": { "AuthHex": {
"BaseUrl": "http://localhost:6703" "BaseUrl": "http://localhost:6703",
"AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9"
}, },
"GeneralLedgerService": { "GeneralLedgerService": {
"BaseUrl": "http://localhost:6704/api/v1/", "BaseUrl": "http://localhost:6704/api/v1/",
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react" import { ArrowLeft, CheckCircle2, Pencil, Plus, Search, Trash2, Wallet, XCircle } from "lucide-react"
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger" import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -10,12 +10,16 @@ import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger" import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both" type AccountTypeFilter = CashBankAccountType | "Both"
@@ -27,23 +31,25 @@ export default function BankAccountsPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both") const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table const [editTarget, setEditTarget] = useState<CashAndBankAccountDto | null>(null)
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below. const [editName, setEditName] = useState("")
useEffect(() => { const [editError, setEditError] = useState<string | null>(null)
let cancelled = false const [editSubmitting, setEditSubmitting] = useState(false)
const [statusPendingId, setStatusPendingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
function load() {
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()]) Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
.then(([banks, gl]) => { .then(([banks, gl]) => {
if (cancelled) return
setAccounts(banks) setAccounts(banks)
setGlAccounts(gl.items) setGlAccounts(gl.items)
}) })
.catch((err) => { .catch((err) => setError(errorMessage(err)))
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
} }
}, [accountType])
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
useEffect(load, [accountType])
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts]) const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
@@ -64,6 +70,57 @@ export default function BankAccountsPage() {
}) })
}, [accounts, search, glAccountsById]) }, [accounts, search, glAccountsById])
function openEdit(a: CashAndBankAccountDto) {
setEditTarget(a)
setEditName(a.accountName)
setEditError(null)
}
async function handleRename() {
if (!editTarget) return
if (!editName.trim()) {
setEditError("Account name is required")
return
}
setEditSubmitting(true)
try {
await bankAccountsApi.updateCash(editTarget.accountId, editName.trim())
toast.success("Cash account renamed")
setEditTarget(null)
load()
} catch (err) {
setEditError(errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
async function toggleStatus(a: CashAndBankAccountDto) {
setStatusPendingId(a.accountId)
try {
await bankAccountsApi.setCashStatus(a.accountId, !a.isActive)
toast.success(a.isActive ? "Cash account deactivated" : "Cash account activated")
load()
} catch (err) {
toast.error("Could not update status", errorMessage(err))
} finally {
setStatusPendingId(null)
}
}
async function handleDelete(a: CashAndBankAccountDto) {
setDeletingId(a.accountId)
try {
await bankAccountsApi.deleteCash(a.accountId)
toast.success("Cash account deleted")
load()
} catch (err) {
toast.error("Could not delete cash account", errorMessage(err))
} finally {
setDeletingId(null)
}
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -167,6 +224,48 @@ export default function BankAccountsPage() {
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell> <TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell> <TableCell className="px-3 py-3.5">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
{a.accountType === CashBankAccountType.Cash ? (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(a)} aria-label={`Edit ${a.accountName}`} title={`Edit ${a.accountName}`}>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(a.isActive ? "text-destructive hover:bg-destructive/10" : "text-success hover:bg-success/10")}
aria-label={a.isActive ? `Deactivate ${a.accountName}` : `Activate ${a.accountName}`}
title={a.isDefault && a.isActive ? "The default cash account can't be deactivated" : a.isActive ? `Deactivate ${a.accountName}` : `Activate ${a.accountName}`}
disabled={statusPendingId === a.accountId || (a.isDefault === true && a.isActive === true)}
onClick={() => toggleStatus(a)}
>
{a.isActive ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${a.accountName}`}
title={a.isDefault ? "The default cash account can't be deleted" : `Delete ${a.accountName}`}
disabled={deletingId === a.accountId || a.isDefault === true}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${a.accountName}?`}
description="This permanently removes the cash account. Cash accounts referenced by posted journal entries cannot be deleted."
confirmLabel="Delete"
onConfirm={() => handleDelete(a)}
/>
</AlertDialog>
</div>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
@@ -176,10 +275,10 @@ export default function BankAccountsPage() {
<Pencil className="size-4" /> <Pencil className="size-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
Editing isn&apos;t available yet — the General Ledger service has no update endpoint for Editing isn&apos;t available yet — the General Ledger service has no update endpoint for bank accounts.
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
) )
@@ -187,6 +286,34 @@ export default function BankAccountsPage() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
<Dialog open={!!editTarget} onOpenChange={(v) => { if (!v) setEditTarget(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>Rename cash account</DialogTitle>
<DialogDescription>
{editTarget?.isDefault
? "This is the default cash account — only its name can be changed."
: "Update this cash account's display name."}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editError}>
<FieldLabel htmlFor="cash-account-name">Account name</FieldLabel>
<Input id="cash-account-name" value={editName} onChange={(e) => setEditName(e.target.value)} aria-invalid={!!editError} />
<FieldError errors={[editError ? { message: editError } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-32" onClick={() => setEditTarget(null)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-32" onClick={handleRename} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
@@ -3,15 +3,14 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Pencil, Plus, Search, ShieldCheck, Trash2, X } from "lucide-react" import { Pencil, Plus, Power, Search, ShieldCheck, X } from "lucide-react"
import { rolesApi } from "@/lib/api/roles" import { rolesApi } from "@/lib/api/roles"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common" import { EntityStatus, PaginationMeta } from "@/types/common"
import { Role } from "@/types/rbac" import { Role } from "@/types/rbac"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -72,14 +71,15 @@ export default function RolesPage() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [search]) }, [search])
async function handleDelete(role: Role) { async function toggleStatus(role: Role) {
const next: EntityStatus = role.status === "Active" ? "Inactive" : "Active"
setActionPendingId(role.roleId) setActionPendingId(role.roleId)
try { try {
await rolesApi.remove(role.roleId) await rolesApi.updateStatus(role.roleId, next)
toast.success("Role deleted", `${role.code} has been removed.`) toast.success(next === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${next.toLowerCase()}.`)
load() load()
} catch (err) { } catch (err) {
toast.error("Could not delete role", errorMessage(err)) toast.error("Could not update role status", errorMessage(err))
} finally { } finally {
setActionPendingId(null) setActionPendingId(null)
} }
@@ -236,29 +236,26 @@ export default function RolesPage() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" className={cn(
aria-label={`Delete ${r.code}`} r.status === "Active"
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`} ? "text-destructive hover:bg-destructive/10"
disabled={r.isSystemRole || actionPendingId === r.roleId} : "text-success hover:bg-success/10"
/> )}
aria-label={r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`}
title={
r.isSystemRole
? "System roles can't be deactivated"
: r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`
} }
disabled={r.isSystemRole || actionPendingId === r.roleId}
onClick={() => toggleStatus(r)}
> >
<Trash2 className="size-4" /> <Power className="size-4" />
</AlertDialogTrigger> </Button>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div> </div>
</div> </div>
</div> </div>
@@ -347,29 +344,26 @@ export default function RolesPage() {
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<AlertDialog>
<AlertDialogTrigger
render={
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed" className={cn(
aria-label={`Delete ${r.code}`} r.status === "Active"
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`} ? "text-destructive hover:bg-destructive/10"
disabled={r.isSystemRole || actionPendingId === r.roleId} : "text-success hover:bg-success/10"
/> )}
aria-label={r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`}
title={
r.isSystemRole
? "System roles can't be deactivated"
: r.status === "Active" ? `Deactivate ${r.code}` : `Activate ${r.code}`
} }
disabled={r.isSystemRole || actionPendingId === r.roleId}
onClick={() => toggleStatus(r)}
> >
<Trash2 className="size-4" /> <Power className="size-4" />
</AlertDialogTrigger> </Button>
<AlertDialogContent
variant="destructive"
title={`Delete ${r.code}?`}
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(r)}
/>
</AlertDialog>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useParams } from "next/navigation" import { useParams } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { Clock, IdCard, Laptop, ShieldCheck } from "lucide-react" import { Clock, IdCard, KeyRound, Laptop, Pencil, ShieldCheck } from "lucide-react"
import { auditLogsApi } from "@/lib/api/audit-logs" import { auditLogsApi } from "@/lib/api/audit-logs"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
@@ -13,7 +13,10 @@ import { AuditLog, LoginActivity } from "@/types/audit"
import { ManagedUser } from "@/types/users" import { ManagedUser } from "@/types/users"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
@@ -36,6 +39,15 @@ export default function UserDetailPage() {
const [statusPending, setStatusPending] = useState(false) const [statusPending, setStatusPending] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [displayName, setDisplayName] = useState("")
const [email, setEmail] = useState("")
const [mobileNumber, setMobileNumber] = useState("")
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [sendingResetLink, setSendingResetLink] = useState(false)
useEffect(() => { useEffect(() => {
if (!Number.isFinite(userId)) return if (!Number.isFinite(userId)) return
setLoadError(null) setLoadError(null)
@@ -85,7 +97,10 @@ export default function UserDetailPage() {
try { try {
const updated = await usersApi.updateStatus(user.userId, { status: nextStatus }) const updated = await usersApi.updateStatus(user.userId, { status: nextStatus })
setUser(updated) setUser(updated)
toast.success(nextStatus === "Active" ? "User activated" : "User deactivated") toast.success(
nextStatus === "Active" ? "User activated" : "User deactivated",
nextStatus === "Inactive" ? "They can no longer sign in." : undefined
)
} catch (err) { } catch (err) {
toast.error("Could not update status", errorMessage(err)) toast.error("Could not update status", errorMessage(err))
} finally { } finally {
@@ -93,6 +108,54 @@ export default function UserDetailPage() {
} }
} }
function openEdit() {
if (!user) return
setDisplayName(user.displayName)
setEmail(user.email ?? "")
setMobileNumber("")
setEditErrors({})
setEditOpen(true)
}
async function handleSave() {
if (!user) return
const nextErrors: Record<string, string> = {}
if (!displayName.trim()) nextErrors.displayName = "Display name is required"
if (!email.trim()) nextErrors.email = "Email is required"
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSaving(true)
try {
const updated = await usersApi.update(user.userId, {
displayName: displayName.trim(),
email: email.trim(),
mobileNumber: mobileNumber.trim() || null,
})
setUser(updated)
setEditOpen(false)
toast.success("User updated", `${updated.displayName} — changes saved to both this app and the login system.`)
} catch (err) {
toast.error("Could not update user", errorMessage(err))
} finally {
setSaving(false)
}
}
async function handleSendResetLink() {
if (!user) return
setSendingResetLink(true)
try {
const result = await usersApi.sendResetLink(user.userId)
const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes"
toast.success("Reset link sent", `Emailed to ${user.email}. Expires at ${expiry}.`)
} catch (err) {
toast.error("Could not send reset link", errorMessage(err))
} finally {
setSendingResetLink(false)
}
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -112,6 +175,15 @@ export default function UserDetailPage() {
<p className="text-base text-muted-foreground">{user.displayName}</p> <p className="text-base text-muted-foreground">{user.displayName}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={openEdit}>
<Pencil className="size-4" />
Edit
</Button>
<Button variant="outline" onClick={handleSendResetLink} disabled={sendingResetLink || !user.email}>
<KeyRound className="size-4" />
{sendingResetLink ? "Sending…" : "Send reset link"}
</Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}> <span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
{user.status === "Active" ? "Active" : "Inactive"} {user.status === "Active" ? "Active" : "Inactive"}
@@ -124,6 +196,7 @@ export default function UserDetailPage() {
/> />
</div> </div>
</div> </div>
</div>
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6"> <section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4"> <div className="flex items-center gap-2.5 border-b border-border pb-4">
@@ -264,6 +337,39 @@ export default function UserDetailPage() {
)} )}
</div> </div>
</section> </section>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit {user.username}</DialogTitle>
<DialogDescription>Updates both this app&apos;s record and the login system&apos;s identity.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.displayName}>
<FieldLabel htmlFor="user-display-name">Display name</FieldLabel>
<Input id="user-display-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} aria-invalid={!!editErrors.displayName} />
<FieldError errors={[editErrors.displayName ? { message: editErrors.displayName } : undefined]} />
</Field>
<Field data-invalid={!!editErrors.email}>
<FieldLabel htmlFor="user-email">Email</FieldLabel>
<Input id="user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!editErrors.email} />
<FieldError errors={[editErrors.email ? { message: editErrors.email } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="user-mobile">Mobile number (optional)</FieldLabel>
<Input id="user-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} placeholder="Leave blank to keep unchanged" />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditOpen(false)} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Eye, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react" import { KeyRound, Pencil, Search, ShieldCheck, UserCheck, UserX, Users as UsersIcon, X } from "lucide-react"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
@@ -45,6 +45,7 @@ export default function UsersPage() {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null) const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null)
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
@@ -105,6 +106,19 @@ export default function UsersPage() {
} }
} }
async function handleSendResetLink(u: ManagedUser) {
setResetLinkPendingId(u.userId)
try {
const result = await usersApi.sendResetLink(u.userId)
const expiry = result.expiresAt ? new Date(result.expiresAt).toLocaleTimeString() : "15 minutes"
toast.success("Reset link sent", `Emailed to ${u.email}. Expires at ${expiry}.`)
} catch (err) {
toast.error("Could not send reset link", errorMessage(err))
} finally {
setResetLinkPendingId(null)
}
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -234,14 +248,6 @@ export default function UsersPage() {
<div className="flex items-center justify-between gap-2 border-t pt-3 text-sm"> <div className="flex items-center justify-between gap-2 border-t pt-3 text-sm">
<span className="text-muted-foreground">{u.roleName ?? "—"}</span> <span className="text-muted-foreground">{u.roleName ?? "—"}</span>
<div className="flex items-center gap-1" data-no-row-nav> <div className="flex items-center gap-1" data-no-row-nav>
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -250,6 +256,17 @@ export default function UsersPage() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Send reset link to ${u.username}`}
title={u.email ? `Send reset link to ${u.username}` : "No email on file"}
disabled={resetLinkPendingId === u.userId || !u.email}
onClick={() => handleSendResetLink(u)}
>
<KeyRound className="size-4" />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -324,14 +341,6 @@ export default function UsersPage() {
</TableCell> </TableCell>
<TableCell className="px-3 py-4" data-no-row-nav> <TableCell className="px-3 py-4" data-no-row-nav>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Link
href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`View ${u.username}`}
title={`View ${u.username}`}
>
<Eye className="size-4" />
</Link>
<Link <Link
href={`/dashboard/settings/users/${u.userId}`} href={`/dashboard/settings/users/${u.userId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
@@ -340,6 +349,17 @@ export default function UsersPage() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Link> </Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Send reset link to ${u.username}`}
title={u.email ? `Send reset link to ${u.username}` : "No email on file"}
disabled={resetLinkPendingId === u.userId || !u.email}
onClick={() => handleSendResetLink(u)}
>
<KeyRound className="size-4" />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -0,0 +1,200 @@
"use client"
import { Suspense, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { AuthShell } from "@/components/auth/AuthShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { authApi } from "@/lib/api/auth"
import { errorMessage } from "@/lib/error-map"
import { Check, Eye, EyeOff } from "lucide-react"
// Destination of the 15-minute reset-link email (ERP_Auth_Service's RecoveryManagerService.
// ForgotPassword, ResetLink branch): `{FrontendUrl}/reset-password?token=...`. Distinct from
// /login/forgot/reset, which is the OTP-based self-service flow reached by typing an email in
// — this page only ever arrives via that emailed link's token.
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
return (
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
{met ? <Check className="size-4" /> : <span>○</span>}
</div>
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
</div>
)
}
function ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get("token")
const [password, setPassword] = useState("")
const [confirm, setConfirm] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const meetsLength = password.length >= 8 && password.length <= 20
const hasUpper = /[A-Z]/.test(password)
const hasNumber = /[0-9]/.test(password)
const hasSpecial = /[^A-Za-z0-9]/.test(password)
const passwordsMatch = password === confirm && password.length > 0
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
if (!token) {
return (
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Reset password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Link invalid</h1>
<p className="mt-3 text-base text-muted-foreground">
This reset link is missing its token. Request a new one from the sign-in page.
</p>
</div>
<div className="mt-8 text-center">
<Link href="/login/forgot" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Request a new link
</Link>
</div>
</AuthShell>
)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!allValid || !token) return
setSubmitting(true)
setSubmitError(null)
try {
await authApi.resetPasswordWithToken({ resetToken: token, newPassword: password, confirmPassword: confirm })
setShowSuccess(true)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<AuthShell>
<div className="text-center">
<p className="text-sm font-semibold tracking-wide text-primary uppercase">Reset password</p>
<h1 className="mt-1 text-3xl font-bold text-foreground">Create a new password</h1>
<p className="mt-3 text-base text-muted-foreground">Choose a strong password to secure your account.</p>
</div>
<form noValidate className="mt-10 space-y-6" onSubmit={handleSubmit}>
{submitError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{submitError}</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-semibold text-foreground">New Password</label>
<div className="relative mt-2">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Enter your new password"
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{showPassword ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-semibold text-foreground">Confirm Password</label>
<div className="relative mt-2">
<Input
id="confirm"
type={showConfirm ? "text" : "password"}
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="h-11 rounded-lg px-4 pr-11 text-base"
placeholder="Confirm your new password"
aria-invalid={!passwordsMatch && confirm.length > 0}
/>
<button
type="button"
onClick={() => setShowConfirm((s) => !s)}
aria-label={showConfirm ? "Hide password" : "Show password"}
className="absolute inset-y-0 right-3 flex items-center text-muted-foreground transition-colors hover:text-foreground"
>
{showConfirm ? <EyeOff className="size-5" /> : <Eye className="size-5" />}
</button>
</div>
{!passwordsMatch && confirm.length > 0 && (
<p className="mt-2 text-sm font-medium text-destructive">Passwords don&apos;t match.</p>
)}
</div>
<div className="space-y-3 rounded-lg bg-primary/5 p-4">
<p className="text-sm font-semibold text-foreground">Password requirements:</p>
<PasswordRequirement met={meetsLength} label="Between 8 and 20 characters" />
<PasswordRequirement met={hasUpper} label="At least 1 uppercase letter" />
<PasswordRequirement met={hasNumber} label="At least 1 number" />
<PasswordRequirement met={hasSpecial} label="At least 1 special character" />
</div>
<Button type="submit" disabled={!allValid || submitting} className="mt-8 h-12 w-full rounded-full text-base font-semibold">
{submitting ? "Changing password…" : "Change password"}
</Button>
<div className="text-center">
<Link href="/login" className="text-sm font-medium text-primary transition-colors hover:text-primary/80">
Back to sign in
</Link>
</div>
</form>
<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
<DialogContent className="w-full max-w-lg sm:max-w-lg">
<DialogHeader className="text-center">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-success/10 text-success">
<Check className="size-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold">Password changed successfully</DialogTitle>
<DialogDescription className="mt-2">Your password has been updated. You can now sign in with your new password.</DialogDescription>
</div>
</div>
</DialogHeader>
<Button onClick={() => router.push("/login")} className="mx-auto mt-6 h-12 w-4/5 rounded-full text-base font-semibold">
Return to sign in
</Button>
</DialogContent>
</Dialog>
</AuthShell>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<AuthShell><Skeleton className="h-96 w-full" /></AuthShell>}>
<ResetPasswordContent />
</Suspense>
)
}
@@ -28,6 +28,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25] const PAGE_SIZE_OPTIONS = [5, 10, 20, 25]
// Mirrors Branch.DefaultCode (backend) — the deployment's seeded branch, which
// BranchService blocks deactivating or deleting (every branch-scoped entity needs
// at least one branch to exist).
const DEFAULT_BRANCH_CODE = "BR-000"
// Mobile has no page-size selector — tapping "Show more" just grows the page size and // Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below. // re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10 const MOBILE_PAGE_SIZE_STEP = 10
@@ -329,12 +333,16 @@ export function BranchMasterPage() {
size="icon-sm" size="icon-sm"
className={cn( className={cn(
item.status === "Active" item.status === "Active"
? "text-destructive hover:bg-destructive/10" ? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
: "text-success hover:bg-success/10" : "text-success hover:bg-success/10"
)} )}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} title={
disabled={actionPendingId === item.branchId} item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
? "The default branch cannot be deactivated"
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
}
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
onClick={() => toggleStatus(item)} onClick={() => toggleStatus(item)}
> >
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} {item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
@@ -345,10 +353,10 @@ export function BranchMasterPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${item.name}`} aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`} title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
disabled={deletingId === item.branchId} disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
/> />
} }
> >
@@ -433,12 +441,16 @@ export function BranchMasterPage() {
size="icon-sm" size="icon-sm"
className={cn( className={cn(
item.status === "Active" item.status === "Active"
? "text-destructive hover:bg-destructive/10" ? "text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
: "text-success hover:bg-success/10" : "text-success hover:bg-success/10"
)} )}
aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`}
title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} title={
disabled={actionPendingId === item.branchId} item.status === "Active" && item.code === DEFAULT_BRANCH_CODE
? "The default branch cannot be deactivated"
: item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`
}
disabled={actionPendingId === item.branchId || (item.status === "Active" && item.code === DEFAULT_BRANCH_CODE)}
onClick={() => toggleStatus(item)} onClick={() => toggleStatus(item)}
> >
{item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />} {item.status === "Active" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
@@ -449,10 +461,10 @@ export function BranchMasterPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${item.name}`} aria-label={`Delete ${item.name}`}
title={`Delete ${item.name}`} title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
disabled={deletingId === item.branchId} disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
/> />
} }
> >
@@ -88,9 +88,10 @@ function AlertDialogContent({
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay data-no-row-nav />
<AlertDialogPrimitive.Popup <AlertDialogPrimitive.Popup
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
data-no-row-nav
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm", "fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+2 -1
View File
@@ -49,9 +49,10 @@ function DialogContent({
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay data-no-row-nav />
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
data-no-row-nav
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className
+5
View File
@@ -35,4 +35,9 @@ export const authApi = {
refresh(): Promise<AuthSession> { refresh(): Promise<AuthSession> {
return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} }) return apiRequest<AuthSession>("/auth/refresh-token", { method: "POST", body: {} })
}, },
/** Consumes a 15-minute reset-link token (from the emailed link's `?token=`) and sets a new password. */
resetPasswordWithToken(request: { resetToken: string; newPassword: string; confirmPassword: string }): Promise<void> {
return apiRequest<void>("/auth/recovery/reset-password-token", { method: "POST", body: request })
},
} }
+16 -1
View File
@@ -245,7 +245,22 @@ export const bankAccountsApi = {
return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request }) return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request })
}, },
// No get()/update(): GL exposes no GET/PUT by id for either bank_account or cash_account today /** Rename only — the default cash account allows this too, just not delete/deactivate. */
updateCash(cashAccountId: number, accountName: string): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>(`/cash-accounts/${cashAccountId}`, { method: "PUT", body: { accountName } })
},
/** Throws (surfaced via errorMessage) if deactivating the default cash account. */
setCashStatus(cashAccountId: number, isActive: boolean): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>(`/cash-accounts/${cashAccountId}/status`, { method: "PATCH", body: { isActive } })
},
/** Throws if this is the default cash account. */
deleteCash(cashAccountId: number): Promise<void> {
return glRequest<void>(`/cash-accounts/${cashAccountId}`, { method: "DELETE" })
},
// No get()/update() for Bank: GL exposes no GET/PUT by id for bank_account today
// (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit"). // (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit").
} }
+12
View File
@@ -8,6 +8,8 @@ import {
ManagedUser, ManagedUser,
PurgeAuthHexIdentityRequest, PurgeAuthHexIdentityRequest,
PurgeAuthHexIdentityResult, PurgeAuthHexIdentityResult,
SendResetLinkResponse,
UpdateManagedUserRequest,
UpdateUserRoleRequest, UpdateUserRoleRequest,
UpdateUserStatusRequest, UpdateUserStatusRequest,
UserTypeOption, UserTypeOption,
@@ -38,6 +40,16 @@ export const usersApi = {
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request }) return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
}, },
/** Edits DisplayName/Email/MobileNumber on both backends (local shadow row + AuthHex identity). */
update(userId: number, request: UpdateManagedUserRequest): Promise<ManagedUser> {
return apiRequest<ManagedUser>(`/users/${userId}`, { method: "PUT", body: request })
},
/** Emails the user a 15-minute password-reset link via AuthHex's forgot-password flow. */
sendResetLink(userId: number): Promise<SendResetLinkResponse> {
return apiRequest<SendResetLinkResponse>(`/users/${userId}/send-reset-link`, { method: "POST" })
},
/** Revokes/restores standing inside ERPCore only — the auth-service credential itself is untouched. */ /** Revokes/restores standing inside ERPCore only — the auth-service credential itself is untouched. */
updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> { updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> {
return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request }) return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request })
@@ -294,6 +294,9 @@ export interface CashAndBankAccountDto {
glAccountCode: string glAccountCode: string
currencyCode: string currencyCode: string
createdAt: string createdAt: string
/** Cash only — null for Bank rows, which carry no such protection yet. */
isActive: boolean | null
isDefault: boolean | null
} }
/** /**
+11
View File
@@ -44,6 +44,17 @@ export interface UpdateUserRoleRequest {
roleId: number roleId: number
} }
/** Edits both backends — the local shadow row, and (when linked) the AuthHex identity itself. */
export interface UpdateManagedUserRequest {
displayName: string
email: string
mobileNumber?: string | null
}
export interface SendResetLinkResponse {
expiresAt: string | null
}
export interface UpdateUserStatusRequest { export interface UpdateUserStatusRequest {
status: EntityStatus status: EntityStatus
} }