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

@@ -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>
);