feat(admin+search): user-mgmt polish, role labels, search keyword index

Admin search now matches against per-card keyword lists so typing
"client portal", "smtp", "tier ladder" lands on the System Settings card
(which hosts those flags). The same keyword list extends the topbar
global search (NAV_CATALOG) so any setting key resolves from the cmd-K
input — settings results sort to the bottom of the dropdown beneath
entity hits.

User management:
- Third action button (Power/PowerOff) enables/disables sign-in from the
  desktop list; mobile card dropdown gains the same item. Backed by the
  existing userProfiles.isActive flag — withAuth already refuses
  disabled sessions with 403.
- UserForm collects first + last name (canonical) alongside displayName,
  with admin email-change behind a confirmation modal. On confirm we
  send the OLD address an automated "your admin changed your sign-in
  email" notice (new template at admin-email-change.ts) and rewrite
  the Better Auth user row.
- Phone field swaps the bare tel input for the shared PhoneInput
  (country combobox + AsYouType formatting + E.164 storage).
- "Manage permissions" link points to /admin/roles?focusUser=… as
  a stepping stone for the future fine-tuned-permissions UI.

Role names normalize through a new ROLE_LABELS + formatRole() helper
in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the
prettifyRoleName in role-list; user-list and user-card now render
"Sales Agent" instead of "sales_agent". Custom roles pass through
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:14:12 +02:00
parent 0ab7055cf1
commit 660553c074
19 changed files with 1257 additions and 400 deletions

View File

@@ -1,8 +1,18 @@
'use client';
import { Clock, Mail, MoreHorizontal, Pencil, Shield, Trash2 } from 'lucide-react';
import {
Clock,
Mail,
MoreHorizontal,
Pencil,
Power,
PowerOff,
Shield,
Trash2,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { formatRole } from '@/lib/constants';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -35,10 +45,19 @@ interface UserCardProps {
user: UserRow;
onEdit: (user: UserRow) => void;
onRemove: (userId: string) => void;
onToggleActive: (user: UserRow) => void;
isRemoving: boolean;
isToggling: boolean;
}
export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps) {
export function UserCard({
user,
onEdit,
onRemove,
onToggleActive,
isRemoving,
isToggling,
}: UserCardProps) {
const initials = deriveInitials(user.displayName || user.email);
const accentClass = user.isSuperAdmin
@@ -75,6 +94,32 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<ConfirmationDialog
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()} disabled={isToggling}>
{user.isActive ? (
<>
<PowerOff className="mr-2 h-3.5 w-3.5" />
Disable sign-in
</>
) : (
<>
<Power className="mr-2 h-3.5 w-3.5 text-emerald-600" />
Enable sign-in
</>
)}
</DropdownMenuItem>
}
title={user.isActive ? 'Disable user' : 'Enable user'}
description={
user.isActive
? `Disable sign-in for "${user.displayName}"? Their account stays intact; they just can't log in until you re-enable.`
: `Re-enable sign-in for "${user.displayName}"?`
}
confirmLabel={user.isActive ? 'Disable' : 'Enable'}
onConfirm={() => onToggleActive(user)}
loading={isToggling}
/>
<ConfirmationDialog
trigger={
<DropdownMenuItem className="text-destructive" onSelect={(e) => e.preventDefault()}>
@@ -118,7 +163,9 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
{/* Role + last login meta */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<ListCardMeta icon={<Shield className="h-3 w-3" />}>{user.role.name}</ListCardMeta>
<ListCardMeta icon={<Shield className="h-3 w-3" />}>
{formatRole(user.role.name)}
</ListCardMeta>
{user.lastLoginAt ? (
<ListCardMeta icon={<Clock className="h-3 w-3" />}>

View File

@@ -2,6 +2,7 @@
import { formatErrorBanner } from '@/lib/api/toast-error';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,7 +15,20 @@ import {
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useUIStore } from '@/stores/ui-store';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
interface Role {
id: string;
@@ -27,6 +41,9 @@ interface UserFormProps {
user?: {
userId: string;
displayName: string;
fullName?: string | null;
firstName?: string | null;
lastName?: string | null;
email: string;
phone: string | null;
isActive: boolean;
@@ -38,18 +55,23 @@ interface UserFormProps {
export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {
const [roles, setRoles] = useState<Role[]>([]);
const [name, setName] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [originalEmail, setOriginalEmail] = useState('');
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(null);
const [roleId, setRoleId] = useState('');
const [isActive, setIsActive] = useState(true);
const [residentialAccess, setResidentialAccess] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const portSlug = useUIStore((s) => s.currentPortSlug);
const isEdit = !!user;
const fullName = `${firstName} ${lastName}`.trim();
useEffect(() => {
if (open) {
@@ -60,19 +82,38 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
useEffect(() => {
if (open) {
if (user) {
setName(user.displayName);
// Prefer canonical first/last from the API; fall back to a best-
// effort split of displayName for older records that pre-date the
// first_name/last_name columns.
const first = user.firstName ?? '';
const last = user.lastName ?? '';
if (first || last) {
setFirstName(first);
setLastName(last);
} else if (user.fullName) {
const parts = user.fullName.split(/\s+/);
setFirstName(parts[0] ?? '');
setLastName(parts.slice(1).join(' '));
} else {
const parts = user.displayName.split(/\s+/);
setFirstName(parts[0] ?? '');
setLastName(parts.slice(1).join(' '));
}
setEmail(user.email);
setOriginalEmail(user.email);
setDisplayName(user.displayName);
setPhone(user.phone ?? '');
setPhoneValue(user.phone ? { e164: user.phone, country: 'US' } : null);
setRoleId(user.role.id);
setIsActive(user.isActive);
setResidentialAccess(user.residentialAccess ?? false);
setPassword('');
} else {
setName('');
setFirstName('');
setLastName('');
setEmail('');
setOriginalEmail('');
setDisplayName('');
setPhone('');
setPhoneValue(null);
setRoleId('');
setIsActive(true);
setResidentialAccess(false);
@@ -82,32 +123,53 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
}
}, [open, user]);
async function handleSubmit(e: React.FormEvent) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Admin email change for an existing user goes through a confirmation
// dialog because it locks the original sign-in identity out — the
// submit path runs after the admin acknowledges. New-user creation
// and same-email saves go straight through.
if (isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase()) {
setEmailConfirmOpen(true);
return;
}
void persist();
}
async function persist() {
setError(null);
setLoading(true);
const phoneE164 = phoneValue?.e164 ?? null;
try {
if (isEdit) {
const emailChanged = email.trim().toLowerCase() !== originalEmail.toLowerCase();
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: {
firstName: firstName || null,
lastName: lastName || null,
fullName: fullName || displayName,
displayName,
phone: phone || null,
email: emailChanged ? email.trim() : undefined,
phone: phoneE164,
roleId,
isActive,
residentialAccess,
notifyEmailChange: emailChanged ? true : undefined,
},
});
} else {
await apiFetch('/api/v1/admin/users', {
method: 'POST',
body: {
name: name || displayName,
name: fullName || displayName,
firstName: firstName || null,
lastName: lastName || null,
email,
password,
displayName,
phone: phone || undefined,
phone: phoneE164 ?? undefined,
roleId,
residentialAccess,
},
@@ -131,53 +193,89 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{!isEdit && (
<>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
</>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="user-first-name">First name</Label>
<Input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-last-name">Last name</Label>
<Input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="user-display-name">Display Name</Label>
<Label htmlFor="user-display-name">Display name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="John Smith"
placeholder={fullName || 'Jane Doe'}
required
/>
<p className="text-xs text-muted-foreground">
How this user appears across the app usually their full name, but they can pick a
nickname.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
<p className="text-xs text-amber-600">
You&apos;ll be asked to confirm the original address will receive an automated
notice that you, the admin, changed their sign-in email.
</p>
) : isEdit ? (
<p className="text-xs text-muted-foreground">
Changing this address is an admin-only override; the user will be notified at the
old address.
</p>
) : null}
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<PhoneInput
id="user-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 555-0123"
value={phoneValue}
onChange={setPhoneValue}
placeholder="Phone number"
/>
</div>
@@ -190,7 +288,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
{formatRole(r.name)}
</SelectItem>
))}
</SelectContent>
@@ -215,13 +313,30 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account Active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in</p>
<Label htmlFor="user-active">Account active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{isEdit && portSlug && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">Fine-tuned permissions</p>
<p className="text-xs text-muted-foreground">
The selected role grants a baseline. To add or remove a specific permission for
this user only, open the role &amp; permissions page.
</p>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/admin/roles?focusUser=${user.userId}` as any}
className="mt-2 inline-block text-xs font-medium text-primary hover:underline"
>
Manage permissions
</Link>
</div>
)}
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
<SheetFooter>
@@ -234,10 +349,37 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create User'}
{loading ? 'Saving...' : isEdit ? 'Save changes' : 'Create user'}
</Button>
</SheetFooter>
</form>
<AlertDialog open={emailConfirmOpen} onOpenChange={setEmailConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Change this user&apos;s sign-in email?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;re about to change <span className="font-medium">{originalEmail}</span> to{' '}
<span className="font-medium">{email}</span>. From now on, they must sign in with
the new address. The original address will receive an automated notification
explaining that an administrator made the change.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
setEmailConfirmOpen(false);
void persist();
}}
disabled={loading}
>
Confirm change
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SheetContent>
</Sheet>
);

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff } from 'lucide-react';
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff, Power, PowerOff } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
@@ -11,6 +11,7 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
import { UserCard } from './user-card';
import { UserForm } from './user-form';
@@ -32,6 +33,7 @@ export function UserList() {
const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserRow | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
@@ -67,6 +69,19 @@ export function UserList() {
}
}
async function handleToggleActive(user: UserRow) {
setTogglingId(user.userId);
try {
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: { isActive: !user.isActive },
});
await fetchUsers();
} finally {
setTogglingId(null);
}
}
const columns: ColumnDef<UserRow, unknown>[] = [
{
accessorKey: 'displayName',
@@ -81,7 +96,7 @@ export function UserList() {
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => <Badge variant="secondary">{row.original.role.name}</Badge>,
cell: ({ row }) => <Badge variant="secondary">{formatRole(row.original.role.name)}</Badge>,
},
{
accessorKey: 'isActive',
@@ -113,7 +128,12 @@ export function UserList() {
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<PermissionGate resource="admin" action="manage_users">
<Button variant="ghost" size="sm" onClick={() => handleEditUser(row.original)}>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(row.original)}
title="Edit user"
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
@@ -124,6 +144,42 @@ export function UserList() {
<Button
variant="ghost"
size="sm"
title={row.original.isActive ? 'Disable sign-in' : 'Enable sign-in'}
disabled={togglingId === row.original.userId}
className={
row.original.isActive
? 'text-muted-foreground hover:text-foreground'
: 'text-emerald-600 hover:text-emerald-700'
}
>
{row.original.isActive ? (
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
<span className="sr-only">
{row.original.isActive ? 'Disable' : 'Enable'}
</span>
</Button>
}
title={row.original.isActive ? 'Disable user' : 'Enable user'}
description={
row.original.isActive
? `Disable sign-in for "${row.original.displayName}"? Their account stays intact; they just can't log in until you re-enable.`
: `Re-enable sign-in for "${row.original.displayName}"?`
}
confirmLabel={row.original.isActive ? 'Disable' : 'Enable'}
onConfirm={() => handleToggleActive(row.original)}
loading={togglingId === row.original.userId}
/>
</PermissionGate>
<PermissionGate resource="admin" action="manage_users">
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
title="Remove from port"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
@@ -140,7 +196,7 @@ export function UserList() {
</div>
),
enableSorting: false,
size: 80,
size: 120,
},
];
@@ -167,7 +223,9 @@ export function UserList() {
user={row.original}
onEdit={handleEditUser}
onRemove={handleRemoveUser}
onToggleActive={handleToggleActive}
isRemoving={deletingId === row.original.userId}
isToggling={togglingId === row.original.userId}
/>
)}
emptyState={