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
@@ -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&apos;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&apos;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&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>
)
}
@@ -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"