a9bf332369
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>
422 lines
15 KiB
TypeScript
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'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'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>
|
|
);
|
|
}
|