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:
@@ -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" />}>
|
||||
|
||||
@@ -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'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 & 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's sign-in email?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'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>
|
||||
);
|
||||
|
||||
@@ -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