Files
TicketingSystem/client/src/pages/admin/Users.tsx
T
josh a9bf332369
Build & Push / Test (client) (push) Successful in 33s
Build & Push / Test (server) (push) Successful in 25s
Build & Push / Build Client (push) Successful in 42s
Build & Push / Build Server (push) Successful in 1m5s
Retheme UI from blue to neutral zinc backgrounds with indigo accents
Removes the blue tint from all dark-mode surfaces by switching CSS
variables to zinc-based neutrals, and replaces decorative blue classes
with indigo across buttons, focus rings, tabs, and links. Semantic blue
(severity badges, status badges, role badges, timeline markers) is
preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 13:29:50 -04:00

422 lines
15 KiB
TypeScript

import { useState } from 'react';
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import Modal from '../../components/Modal';
import { User, Role } from '../../types';
import { useAuth } from '../../contexts/AuthContext';
import {
useUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
} from '../../api/queries';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface UserForm {
username: string;
email: string;
displayName: string;
password: string;
role: Role;
}
const BLANK_FORM: UserForm = {
username: '',
email: '',
displayName: '',
password: '',
role: 'AGENT',
};
const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin',
AGENT: 'Agent',
USER: 'User',
};
const ROLE_BADGE: Record<Role, string> = {
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
AGENT: 'Manage tickets and automation — logs in with password and can authenticate via API key',
USER: 'Basic access — view tickets and add comments only',
};
export default function AdminUsers() {
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const [modal, setModal] = useState<'add' | 'edit' | null>(null);
const [selected, setSelected] = useState<User | null>(null);
const [form, setForm] = useState<UserForm>(BLANK_FORM);
const [error, setError] = useState('');
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const [deleting, setDeleting] = useState<User | null>(null);
const [rotating, setRotating] = useState<User | null>(null);
const submitting = createUser.isPending || updateUser.isPending;
const openAdd = () => {
setForm(BLANK_FORM);
setError('');
setNewApiKey(null);
setModal('add');
};
const openEdit = (u: User) => {
setSelected(u);
setForm({
username: u.username,
email: u.email,
displayName: u.displayName,
password: '',
role: u.role,
});
setError('');
setNewApiKey(null);
setModal('edit');
};
const closeModal = () => {
setModal(null);
setSelected(null);
setNewApiKey(null);
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const payload: Record<string, string> = {
username: form.username,
email: form.email,
displayName: form.displayName,
role: form.role,
};
if (form.password) payload.password = form.password;
const created = await createUser.mutateAsync(payload);
if (created.apiKey) setNewApiKey(created.apiKey);
else closeModal();
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } };
setError(e.response?.data?.error ?? 'Failed to create user');
}
};
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selected) return;
setError('');
try {
const payload: Record<string, string> = {
email: form.email,
displayName: form.displayName,
role: form.role,
};
if (form.password) payload.password = form.password;
await updateUser.mutateAsync({ id: selected.id, data: payload });
closeModal();
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } };
setError(e.response?.data?.error ?? 'Failed to update user');
}
};
const confirmDelete = async () => {
if (!deleting) return;
try {
await deleteUser.mutateAsync(deleting.id);
toast.success(`Deleted ${deleting.displayName}`);
} catch (e) {
toast.error((e as Error).message || 'Failed to delete user');
}
setDeleting(null);
};
const confirmRegenerate = async () => {
if (!rotating) return;
try {
const updated = await updateUser.mutateAsync({
id: rotating.id,
data: { regenerateApiKey: true },
});
setNewApiKey(updated.apiKey ?? null);
setSelected(rotating);
setModal('edit');
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate key');
}
setRotating(null);
};
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
const inputClass =
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent';
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
return (
<Layout
title="Users"
action={
<button
onClick={openAdd}
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors"
>
<Plus size={14} />
Add User
</button>
}
>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-gray-800">
<tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
User
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Username
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Role
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Email
</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-800/50">
<td className="px-5 py-3 font-medium text-gray-100">{u.displayName}</td>
<td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td>
<td className="px-5 py-3">
<span
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${ROLE_BADGE[u.role]}`}
>
{ROLE_LABELS[u.role]}
</span>
</td>
<td className="px-5 py-3 text-gray-400">{u.email}</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-2">
{u.role === 'AGENT' && (
<button
onClick={() => setRotating(u)}
className="text-gray-600 hover:text-gray-300 transition-colors"
title="Regenerate API key"
>
<RefreshCw size={14} />
</button>
)}
<button
onClick={() => openEdit(u)}
className="text-gray-600 hover:text-gray-300 transition-colors"
>
<Pencil size={14} />
</button>
{u.id !== authUser?.id && (
<button
onClick={() => setDeleting(u)}
className="text-gray-600 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Add / Edit Modal */}
{modal && (
<Modal
title={modal === 'add' ? 'Add User' : `Edit ${selected?.displayName}`}
onClose={closeModal}
>
{newApiKey ? (
<div className="space-y-4">
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
<p className="text-sm font-medium text-amber-400 mb-2">
API Key copy it now, it won&apos;t be shown again
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-3 py-2 font-mono break-all">
{newApiKey}
</code>
<button
onClick={() => copyToClipboard(newApiKey)}
className="flex-shrink-0 text-amber-400 hover:text-amber-300 transition-colors"
>
{copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
</div>
<button
onClick={closeModal}
className="w-full bg-indigo-600 text-white py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors"
>
Done
</button>
</div>
) : (
<form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
{modal === 'add' && (
<div>
<label className={labelClass}>Username</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
required
className={inputClass}
/>
</div>
)}
<div>
<label className={labelClass}>Display Name</label>
<input
type="text"
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>
Password{' '}
{modal === 'edit' && (
<span className="text-gray-500 font-normal">(leave blank to keep current)</span>
)}
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required={modal === 'add'}
className={inputClass}
placeholder={modal === 'edit' ? '••••••••' : ''}
/>
</div>
<div>
<label className={labelClass}>Role</label>
<select
value={form.role}
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as Role }))}
className={inputClass}
>
<option value="AGENT">Agent</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
</button>
</div>
</form>
)}
</Modal>
)}
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleting?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. Their tickets and comments are preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate API key for {rotating?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
The old key will stop working immediately. You&apos;ll see the new key once make
sure whatever uses it can be updated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRegenerate}>Rotate key</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}