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,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={