From 61330f392722977dd5768d397e374e76b27f1042 Mon Sep 17 00:00:00 2001 From: Dhananjaya99 <152056742+ashanruu@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:54:28 +0530 Subject: [PATCH] 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. --- .../Controllers/GeneralLedgerController.cs | 8 +- .../ERPCore/Controllers/UsersController.cs | 16 +- Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs | 9 + Backend/ERPCore/Dtos/Users/UserDtos.cs | 15 ++ .../Infra/Auth/AuthHex/AuthHexClient.cs | 33 ++- .../Infra/Auth/AuthHex/IAuthHexClient.cs | 9 + Backend/ERPCore/Services/Hrm/BranchService.cs | 12 ++ .../Interfaces/IUserManagementService.cs | 21 +- Backend/ERPCore/Services/RoleService.cs | 25 ++- .../ERPCore/Services/UserManagementService.cs | 48 +++++ Backend/ERPCore/System/Errors/ErrorCodes.cs | 1 + Backend/ERPCore/appsettings.Development.json | 3 +- Backend/ERPCore/appsettings.Production.json | 3 +- .../dashboard/accounts/bank-accounts/page.tsx | 179 +++++++++++++--- .../app/dashboard/settings/roles/page.tsx | 100 ++++----- .../dashboard/settings/users/[id]/page.tsx | 132 ++++++++++-- .../app/dashboard/settings/users/page.tsx | 54 +++-- .../erp-system/app/reset-password/page.tsx | 200 ++++++++++++++++++ .../components/settings/BranchMasterPage.tsx | 36 ++-- .../erp-system/components/ui/alert-dialog.tsx | 3 +- Frontend/erp-system/components/ui/dialog.tsx | 3 +- Frontend/erp-system/lib/api/auth.ts | 5 + Frontend/erp-system/lib/api/general-ledger.ts | 17 +- Frontend/erp-system/lib/api/users.ts | 12 ++ Frontend/erp-system/types/general-ledger.ts | 3 + Frontend/erp-system/types/users.ts | 11 + 26 files changed, 817 insertions(+), 141 deletions(-) create mode 100644 Frontend/erp-system/app/reset-password/page.tsx diff --git a/Backend/ERPCore/Controllers/GeneralLedgerController.cs b/Backend/ERPCore/Controllers/GeneralLedgerController.cs index 837a942..ac80666 100644 --- a/Backend/ERPCore/Controllers/GeneralLedgerController.cs +++ b/Backend/ERPCore/Controllers/GeneralLedgerController.cs @@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase [HttpPut("{**path}")] public Task Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct); + [HttpPatch("{**path}")] + public Task Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct); + + [HttpDelete("{**path}")] + public Task Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct); + private async Task 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); return new ContentResult { diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs index 171157a..1ac53c7 100644 --- a/Backend/ERPCore/Controllers/UsersController.cs +++ b/Backend/ERPCore/Controllers/UsersController.cs @@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase public async Task> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct) => Ok(await _users.UpdateRoleAsync(userId, request, ct)); - /// Revokes/restores standing inside ERPCore only — see . + /// Edits DisplayName/Email/MobileNumber on both backends — see . + [HttpPut("{userId:int}")] + [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct) + => Ok(await _users.UpdateAsync(userId, request, ct)); + + /// Revokes/restores standing in both ERPCore and AuthHex — see . [HttpPatch("{userId:int}/status")] [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct) => Ok(await _users.SetStatusAsync(userId, request.Status, ct)); + /// Emails the user a 15-minute password-reset link — see . + [HttpPost("{userId:int}/send-reset-link")] + [ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SendResetLink(int userId, CancellationToken ct) + => Ok(await _users.SendResetLinkAsync(userId, ct)); + /// Device/login history ERPCore captures itself — not AuthHex's "currently active sessions" /// (which only ever reports the caller's own). See . [HttpGet("{userId:int}/login-devices")] diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs index 750eb67..0282282 100644 --- a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs +++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs @@ -44,6 +44,15 @@ public sealed class DeletedAuthHexUser public string? Email { get; set; } } +/// Admin-only profile edit — any field left null is left unchanged upstream. +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 { /// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted. diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs index 40fe0e2..597fe83 100644 --- a/Backend/ERPCore/Dtos/Users/UserDtos.cs +++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs @@ -67,5 +67,20 @@ public sealed class UpdateUserStatusRequest [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; } } +/// +/// 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. +/// +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); + /// AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough). public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description); diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs index 5a4789e..df53201 100644 --- a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs +++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs @@ -93,16 +93,35 @@ public sealed class AuthHexClient : IAuthHexClient => CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct); public Task DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct) - { - 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( + => CallAsync( "user", "deleteUser", new { email, mobileNumber, nic, userName = username }, null, ct, - extraHeaders: new Dictionary { ["X-Admin-Api-Key"] = adminApiKey }); + extraHeaders: AdminHeaders()); + + public Task AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct) + => CallAsync( + "user", "AdminUpdateUser", + new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber }, + null, ct, + extraHeaders: AdminHeaders()); + + public Task AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct) + => CallAsync( + "user", "AdminSetUserStatus", + new { userId, isActive }, + null, ct, + extraHeaders: AdminHeaders()); + + /// Shared guard for every admin-only AuthHex function — throws up front rather + /// than leaving a misconfigured deployment to fail opaquely on the network call. + private Dictionary 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 { ["X-Admin-Api-Key"] = adminApiKey }; } // ---- RecoveryManager ------------------------------------------------ diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs index db2e1fa..23ce724 100644 --- a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs +++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs @@ -38,6 +38,15 @@ public interface IAuthHexClient /// Task DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct); + /// Admin-only: updates another user's identity fields (not self-service — + /// only ever touches the caller's own account). + /// Gated by AuthHex:AdminApiKey. + Task AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct); + + /// Admin-only: activates/deactivates another user's AuthHex identity and, when + /// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey. + Task AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct); + // RecoveryManager (POST /api/recovery) Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct); Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct); diff --git a/Backend/ERPCore/Services/Hrm/BranchService.cs b/Backend/ERPCore/Services/Hrm/BranchService.cs index 9c7cfc8..72ec93b 100644 --- a/Backend/ERPCore/Services/Hrm/BranchService.cs +++ b/Backend/ERPCore/Services/Hrm/BranchService.cs @@ -118,6 +118,12 @@ public sealed class BranchService : IBranchService var branch = await _branches.GetByIdAsync(branchId, ct) ?? 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.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); @@ -128,6 +134,12 @@ public sealed class BranchService : IBranchService var branch = await _branches.GetByIdAsync(branchId, ct) ?? 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); try { diff --git a/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs index 9ebad23..a43a112 100644 --- a/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs +++ b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs @@ -17,16 +17,27 @@ public interface IUserManagementService Task CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default); Task UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default); + /// + /// 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). + /// + Task UpdateAsync(int userId, UpdateManagedUserRequest request, CancellationToken ct = default); + /// /// Flips the local shadow row's status (Active/Inactive) — this is what - /// BranchAccessService and the rest of ERPCore gate on. AuthHex has no - /// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus - /// is self-service, acting on whoever's bearer token is passed), so the - /// credential itself stays live upstream; this only revokes the account's - /// standing inside ERPCore. + /// BranchAccessService and the rest of ERPCore gate on — and, when the user has a + /// linked AuthHex identity, also flips it there via the admin-only AdminSetUserStatus + /// function (deactivating additionally invalidates their live AuthHex sessions), so a + /// user shown Inactive here can no longer log in at all, not just inside ERPCore. /// Task SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default); + /// + /// 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. + /// + Task SendResetLinkAsync(int userId, CancellationToken ct = default); + /// AuthHex UserType options for the create-user form's select. Task> ListUserTypesAsync(CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/RoleService.cs b/Backend/ERPCore/Services/RoleService.cs index 34d0c5c..16257ab 100644 --- a/Backend/ERPCore/Services/RoleService.cs +++ b/Backend/ERPCore/Services/RoleService.cs @@ -22,6 +22,7 @@ public sealed class RoleService : IRoleService private readonly IRepository _rolePermissions; private readonly IRepository _permissions; private readonly IRepository _employees; + private readonly IRepository _users; private readonly ICurrentUser _currentUser; private readonly IAuthHexClient _authHex; private readonly IBranchConfigService _branchConfig; @@ -29,12 +30,14 @@ public sealed class RoleService : IRoleService public RoleService( IRepository roles, IRepository rolePermissions, IRepository permissions, - IRepository employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow) + IRepository employees, IRepository 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; @@ -128,6 +131,9 @@ public sealed class RoleService : IRoleService var role = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + if (status == EntityStatus.Inactive) + await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct); + role.Status = status; role.UpdatedAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); @@ -138,6 +144,8 @@ public sealed class RoleService : IRoleService var role = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct); + try { await _authHex.DeleteRoleAsync(role.AuthRoleId, ct); @@ -151,6 +159,21 @@ public sealed class RoleService : IRoleService await _uow.SaveChangesAsync(ct); } + /// + /// 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. + /// + 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 GetPermissionsAsync(int roleId, CancellationToken ct = default) { _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); diff --git a/Backend/ERPCore/Services/UserManagementService.cs b/Backend/ERPCore/Services/UserManagementService.cs index 048dd46..fd605b1 100644 --- a/Backend/ERPCore/Services/UserManagementService.cs +++ b/Backend/ERPCore/Services/UserManagementService.cs @@ -173,17 +173,65 @@ public sealed class UserManagementService : IUserManagementService return Map(user); } + public async Task 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 SetStatusAsync(int userId, EntityStatus status, 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."); + // 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; await _uow.SaveChangesAsync(ct); return Map(user); } + public async Task 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> ListUserTypesAsync(CancellationToken ct = default) { var userTypes = await _authHex.ListUserTypesAsync(ct); diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 57297c0..3c29e09 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -56,6 +56,7 @@ public static class ErrorCodes public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; 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) public const string TemplateInUse = "TEMPLATE_IN_USE"; diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index fb17e25..a16f2ba 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -9,7 +9,8 @@ "DefaultConnection": "server=187.127.102.190;port=3306;database=erpcore;user=poojathmi;password=poojathmi@hexdive.com" }, "AuthHex": { - "BaseUrl": "http://localhost:5011" + "BaseUrl": "http://localhost:5011", + "AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9" }, "GeneralLedgerService": { "BaseUrl": "https://localhost:7024/api/v1/" diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json index 890c884..e6047ff 100644 --- a/Backend/ERPCore/appsettings.Production.json +++ b/Backend/ERPCore/appsettings.Production.json @@ -3,7 +3,8 @@ "SigningKey": "" }, "AuthHex": { - "BaseUrl": "http://localhost:6703" + "BaseUrl": "http://localhost:6703", + "AdminApiKey": "fd85fb9db2e0533fe82a1652ad42936b5a313a9146dc536e46b1e736861290c9" }, "GeneralLedgerService": { "BaseUrl": "http://localhost:6704/api/v1/", diff --git a/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx index 7dd938c..79d2080 100644 --- a/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx +++ b/Frontend/erp-system/app/dashboard/accounts/bank-accounts/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react" 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 { errorMessage } from "@/lib/error-map" @@ -10,12 +10,16 @@ import { formatReportDate } from "@/lib/format" import { cn } from "@/lib/utils" import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger" +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" 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" type AccountTypeFilter = CashBankAccountType | "Both" @@ -27,23 +31,25 @@ export default function BankAccountsPage() { const [search, setSearch] = useState("") const [accountType, setAccountType] = useState("Both") - // 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(() => { - let cancelled = false + const [editTarget, setEditTarget] = useState(null) + const [editName, setEditName] = useState("") + const [editError, setEditError] = useState(null) + const [editSubmitting, setEditSubmitting] = useState(false) + const [statusPendingId, setStatusPendingId] = useState(null) + const [deletingId, setDeletingId] = useState(null) + + function load() { Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()]) .then(([banks, gl]) => { - if (cancelled) return setAccounts(banks) setGlAccounts(gl.items) }) - .catch((err) => { - if (!cancelled) setError(errorMessage(err)) - }) - return () => { - cancelled = true - } - }, [accountType]) + .catch((err) => setError(errorMessage(err))) + } + + // 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]) @@ -64,6 +70,57 @@ export default function BankAccountsPage() { }) }, [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 (
@@ -167,19 +224,61 @@ export default function BankAccountsPage() { {a.currencyCode} {formatReportDate(a.createdAt)} - - - } - > - - - - Editing isn't available yet — the General Ledger service has no update endpoint for - {a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts. - - + {a.accountType === CashBankAccountType.Cash ? ( +
+ + + + + } + > + + + handleDelete(a)} + /> + +
+ ) : ( + + + } + > + + + + Editing isn't available yet — the General Ledger service has no update endpoint for bank accounts. + + + )}
) @@ -187,6 +286,34 @@ export default function BankAccountsPage() { )} + + { if (!v) setEditTarget(null) }}> + + + Rename cash account + + {editTarget?.isDefault + ? "This is the default cash account — only its name can be changed." + : "Update this cash account's display name."} + + + + + Account name + setEditName(e.target.value)} aria-invalid={!!editError} /> + + + +
+ + +
+
+
) } diff --git a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx index 058f621..86449d1 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx @@ -3,15 +3,14 @@ import { useEffect, useState } from "react" import Link from "next/link" 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 { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { PaginationMeta } from "@/types/common" +import { EntityStatus, PaginationMeta } from "@/types/common" import { Role } from "@/types/rbac" -import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" @@ -72,14 +71,15 @@ export default function RolesPage() { return () => clearTimeout(t) }, [search]) - async function handleDelete(role: Role) { + async function toggleStatus(role: Role) { + const next: EntityStatus = role.status === "Active" ? "Inactive" : "Active" setActionPendingId(role.roleId) try { - await rolesApi.remove(role.roleId) - toast.success("Role deleted", `${role.code} has been removed.`) + await rolesApi.updateStatus(role.roleId, next) + toast.success(next === "Active" ? "Role activated" : "Role deactivated", `${role.code} is now ${next.toLowerCase()}.`) load() } catch (err) { - toast.error("Could not delete role", errorMessage(err)) + toast.error("Could not update role status", errorMessage(err)) } finally { setActionPendingId(null) } @@ -236,29 +236,26 @@ export default function RolesPage() { > - - - } - > - - - handleDelete(r)} - /> - +
@@ -347,29 +344,26 @@ export default function RolesPage() { - - - } - > - - - handleDelete(r)} - /> - + diff --git a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx index 1db25a1..ea8a43b 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useParams } from "next/navigation" 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 { usersApi } from "@/lib/api/users" @@ -13,7 +13,10 @@ import { AuditLog, LoginActivity } from "@/types/audit" import { ManagedUser } from "@/types/users" 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 { Switch } from "@/components/ui/switch" import { toast } from "@/components/ui/toast" @@ -36,6 +39,15 @@ export default function UserDetailPage() { 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>({}) + const [saving, setSaving] = useState(false) + + const [sendingResetLink, setSendingResetLink] = useState(false) + useEffect(() => { if (!Number.isFinite(userId)) return setLoadError(null) @@ -85,7 +97,10 @@ export default function UserDetailPage() { try { const updated = await usersApi.updateStatus(user.userId, { status: nextStatus }) 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) { toast.error("Could not update status", errorMessage(err)) } 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 = {} + 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 (
@@ -112,16 +175,26 @@ export default function UserDetailPage() {

{user.displayName}

-
- - {user.status === "Active" ? "Active" : "Inactive"} - - +
+ + +
+ + {user.status === "Active" ? "Active" : "Inactive"} + + +
@@ -264,6 +337,39 @@ export default function UserDetailPage() { )}
+ + + + + Edit {user.username} + Updates both this app's record and the login system's identity. + + + + Display name + setDisplayName(e.target.value)} aria-invalid={!!editErrors.displayName} /> + + + + Email + setEmail(e.target.value)} aria-invalid={!!editErrors.email} /> + + + + Mobile number (optional) + setMobileNumber(e.target.value)} placeholder="Leave blank to keep unchanged" /> + + +
+ + +
+
+
) } diff --git a/Frontend/erp-system/app/dashboard/settings/users/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/page.tsx index e5221fb..cab838a 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import Link from "next/link" 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 { errorMessage } from "@/lib/error-map" @@ -45,6 +45,7 @@ export default function UsersPage() { const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") const [actionPendingId, setActionPendingId] = useState(null) + const [resetLinkPendingId, setResetLinkPendingId] = useState(null) const [stats, setStats] = useState<{ total: number; active: number; admins: number } | null>(null) 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 (
@@ -234,14 +248,6 @@ export default function UsersPage() {
{u.roleName ?? "—"}
- - - +
+ ) +} + +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(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 ( + +
+

Reset password

+

Link invalid

+

+ This reset link is missing its token. Request a new one from the sign-in page. +

+
+
+ + Request a new link + +
+
+ ) + } + + 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 ( + +
+

Reset password

+

Create a new password

+

Choose a strong password to secure your account.

+
+ +
+ {submitError && ( +
{submitError}
+ )} + +
+ +
+ setPassword(e.target.value)} + className="h-11 rounded-lg px-4 pr-11 text-base" + placeholder="Enter your new password" + /> + +
+
+ +
+ +
+ 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} + /> + +
+ {!passwordsMatch && confirm.length > 0 && ( +

Passwords don't match.

+ )} +
+ +
+

Password requirements:

+ + + + +
+ + + +
+ + Back to sign in + +
+
+ + + + +
+
+ +
+
+ Password changed successfully + Your password has been updated. You can now sign in with your new password. +
+
+
+ +
+
+
+ ) +} + +export default function ResetPasswordPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/components/settings/BranchMasterPage.tsx b/Frontend/erp-system/components/settings/BranchMasterPage.tsx index d3876c0..c61195c 100644 --- a/Frontend/erp-system/components/settings/BranchMasterPage.tsx +++ b/Frontend/erp-system/components/settings/BranchMasterPage.tsx @@ -28,6 +28,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { toast } from "@/components/ui/toast" 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 // re-fetches from page 1, so previously loaded cards stay put and more append below. const MOBILE_PAGE_SIZE_STEP = 10 @@ -329,12 +333,16 @@ export function BranchMasterPage() { size="icon-sm" className={cn( 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" )} aria-label={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} - title={item.status === "Active" ? `Deactivate ${item.name}` : `Activate ${item.name}`} - disabled={actionPendingId === item.branchId} + title={ + 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)} > {item.status === "Active" ? : } @@ -345,10 +353,10 @@ export function BranchMasterPage() {