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:
@@ -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<AccountTypeFilter>("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<CashAndBankAccountDto | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
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()])
|
||||
.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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -167,19 +224,61 @@ export default function BankAccountsPage() {
|
||||
<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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Editing isn't available yet — the General Ledger service has no update endpoint for
|
||||
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{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>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
|
||||
}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Editing isn't available yet — the General Ledger service has no update endpoint for bank accounts.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
@@ -187,6 +286,34 @@ export default function BankAccountsPage() {
|
||||
</TableBody>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
<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 ${r.code}`}
|
||||
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
|
||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
r.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "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)}
|
||||
>
|
||||
<Power className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,29 +344,26 @@ export default function RolesPage() {
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<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 ${r.code}`}
|
||||
title={r.isSystemRole ? "System roles can't be deleted" : `Delete ${r.code}`}
|
||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
r.status === "Active"
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "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)}
|
||||
>
|
||||
<Power className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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<Record<string, string>>({})
|
||||
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<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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -112,16 +175,26 @@ export default function UserDetailPage() {
|
||||
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
||||
{user.status === "Active" ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={user.status === "Active"}
|
||||
onCheckedChange={handleToggleStatus}
|
||||
disabled={statusPending}
|
||||
aria-label={user.status === "Active" ? "Deactivate user" : "Activate user"}
|
||||
/>
|
||||
<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">
|
||||
<span className={cn("text-sm font-medium", user.status === "Active" ? "text-foreground" : "text-muted-foreground")}>
|
||||
{user.status === "Active" ? "Active" : "Inactive"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={user.status === "Active"}
|
||||
onCheckedChange={handleToggleStatus}
|
||||
disabled={statusPending}
|
||||
aria-label={user.status === "Active" ? "Deactivate user" : "Activate user"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,6 +337,39 @@ export default function UserDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</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's record and the login system'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const [resetLinkPendingId, setResetLinkPendingId] = useState<number | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">
|
||||
<span className="text-muted-foreground">{u.roleName ?? "—"}</span>
|
||||
<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
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
@@ -250,6 +256,17 @@ export default function UsersPage() {
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -324,14 +341,6 @@ export default function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-4" data-no-row-nav>
|
||||
<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
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
@@ -340,6 +349,17 @@ export default function UsersPage() {
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</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
|
||||
type="button"
|
||||
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'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"
|
||||
|
||||
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" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
@@ -345,10 +353,10 @@ export function BranchMasterPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}`}
|
||||
title={`Delete ${item.name}`}
|
||||
disabled={deletingId === item.branchId}
|
||||
title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
|
||||
disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -433,12 +441,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" ? <XCircle className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||
@@ -449,10 +461,10 @@ export function BranchMasterPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}`}
|
||||
title={`Delete ${item.name}`}
|
||||
disabled={deletingId === item.branchId}
|
||||
title={item.code === DEFAULT_BRANCH_CODE ? "The default branch cannot be deleted" : `Delete ${item.name}`}
|
||||
disabled={deletingId === item.branchId || item.code === DEFAULT_BRANCH_CODE}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -88,9 +88,10 @@ function AlertDialogContent({
|
||||
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogOverlay data-no-row-nav />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-no-row-nav
|
||||
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",
|
||||
"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",
|
||||
|
||||
@@ -49,9 +49,10 @@ function DialogContent({
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay data-no-row-nav />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
data-no-row-nav
|
||||
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",
|
||||
className
|
||||
|
||||
@@ -35,4 +35,9 @@ export const authApi = {
|
||||
refresh(): Promise<AuthSession> {
|
||||
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 })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -245,7 +245,22 @@ export const bankAccountsApi = {
|
||||
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").
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
ManagedUser,
|
||||
PurgeAuthHexIdentityRequest,
|
||||
PurgeAuthHexIdentityResult,
|
||||
SendResetLinkResponse,
|
||||
UpdateManagedUserRequest,
|
||||
UpdateUserRoleRequest,
|
||||
UpdateUserStatusRequest,
|
||||
UserTypeOption,
|
||||
@@ -38,6 +40,16 @@ export const usersApi = {
|
||||
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. */
|
||||
updateStatus(userId: number, request: UpdateUserStatusRequest): Promise<ManagedUser> {
|
||||
return apiRequest<ManagedUser>(`/users/${userId}/status`, { method: "PATCH", body: request })
|
||||
|
||||
@@ -294,6 +294,9 @@ export interface CashAndBankAccountDto {
|
||||
glAccountCode: string
|
||||
currencyCode: string
|
||||
createdAt: string
|
||||
/** Cash only — null for Bank rows, which carry no such protection yet. */
|
||||
isActive: boolean | null
|
||||
isDefault: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,17 @@ export interface UpdateUserRoleRequest {
|
||||
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 {
|
||||
status: EntityStatus
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user