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:
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user