213 lines
8.6 KiB
TypeScript
213 lines
8.6 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import * as z from 'zod';
|
|
import { api } from '../api';
|
|
import { toast } from 'sonner';
|
|
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
|
import { format } from 'date-fns';
|
|
import { Button } from '../components/ui/button';
|
|
import { Dialog } from '../components/ui/dialog';
|
|
import { Input } from '../components/ui/input';
|
|
import { Loader2, AlertTriangle, UserPlus, Pencil, Trash2 } from 'lucide-react';
|
|
import { cn } from '../lib/utils';
|
|
|
|
const userSchema = z.object({
|
|
fullName: z.string().min(2, 'Full name is required'),
|
|
email: z.string().email('Invalid email address'),
|
|
role: z.enum(['admin', 'moderator']),
|
|
});
|
|
|
|
type UserFormData = z.infer<typeof userSchema>;
|
|
|
|
type User = UserFormData & { created_at: string };
|
|
|
|
const columnHelper = createColumnHelper<User>();
|
|
|
|
const UserForm = ({ user, onClose }: { user: User | null; onClose: () => void }) => {
|
|
const queryClient = useQueryClient();
|
|
const isEditing = !!user;
|
|
|
|
const { register, handleSubmit, formState: { errors }, reset } = useForm<UserFormData>({
|
|
resolver: zodResolver(userSchema),
|
|
defaultValues: user ? { fullName: user.fullName, email: user.email, role: user.role } : { role: 'moderator' },
|
|
});
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (data: UserFormData) => {
|
|
return isEditing ? api.put(`/api/admin/users/${user.email}`, data) : api.post('/api/admin/users', data);
|
|
},
|
|
onSuccess: () => {
|
|
toast.success(isEditing ? 'User updated successfully' : 'User created successfully');
|
|
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
|
onClose();
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.error || 'An error occurred');
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
reset(user ? { fullName: user.fullName, email: user.email, role: user.role } : { role: 'moderator' });
|
|
}, [user, reset]);
|
|
|
|
const onSubmit = (data: UserFormData) => mutation.mutate(data);
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
<div>
|
|
<label className="text-xs font-mono text-neutral-400">Full Name</label>
|
|
<Input {...register('fullName')} className="mt-1" />
|
|
{errors.fullName && <p className="text-red-400 text-sm mt-1">{errors.fullName.message}</p>}
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-mono text-neutral-400">Email</label>
|
|
<Input {...register('email')} disabled={isEditing} className="mt-1" />
|
|
{errors.email && <p className="text-red-400 text-sm mt-1">{errors.email.message}</p>}
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-mono text-neutral-400">Role</label>
|
|
<select {...register('role')} className="w-full mt-1 p-2 bg-neutral-800 border border-neutral-700 rounded-md focus:ring-2 focus:ring-green-accent focus:border-green-accent outline-none">
|
|
<option value="moderator">Moderator</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
{errors.role && <p className="text-red-400 text-sm mt-1">{errors.role.message}</p>}
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
|
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? <Loader2 className="animate-spin h-4 w-4" /> : 'Save'}</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const UserManagementPage = () => {
|
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: users, isLoading, isError } = useQuery<User[]>({
|
|
queryKey: ['adminUsers'],
|
|
queryFn: async () => {
|
|
const res = await api.get('/api/admin/users');
|
|
return res.data;
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (email: string) => api.delete(`/api/admin/users/${email}`),
|
|
onSuccess: () => {
|
|
toast.success('User deleted successfully');
|
|
queryClient.invalidateQueries({ queryKey: ['adminUsers'] });
|
|
setIsDeleteConfirmOpen(false);
|
|
setSelectedUser(null);
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error.response?.data?.error || 'Failed to delete user');
|
|
}
|
|
});
|
|
|
|
const columns = [
|
|
columnHelper.accessor('fullName', {
|
|
header: 'User',
|
|
cell: info => (
|
|
<div className='flex flex-col'>
|
|
<span className='font-medium text-neutral-100'>{info.getValue()}</span>
|
|
<span className='text-xs text-neutral-400 font-mono'>{info.row.original.email}</span>
|
|
</div>
|
|
)
|
|
}),
|
|
columnHelper.accessor('role', {
|
|
header: 'Role',
|
|
cell: info => <span className={cn('px-2 py-1 text-xs rounded-full font-mono', info.getValue() === 'admin' ? 'bg-green-accent/10 text-green-accent' : 'bg-neutral-700 text-neutral-300')}>{info.getValue()}</span>,
|
|
}),
|
|
columnHelper.accessor('created_at', {
|
|
header: 'Created',
|
|
cell: info => <span className="text-neutral-400">{format(new Date(info.getValue()), 'MMM d, yyyy')}</span>,
|
|
}),
|
|
columnHelper.accessor('email', {
|
|
header: 'Actions',
|
|
id: 'actions',
|
|
cell: ({ row }) => (
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => { setSelectedUser(row.original); setIsFormOpen(true); }}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-400 hover:bg-red-500/10" onClick={() => { setSelectedUser(row.original); setIsDeleteConfirmOpen(true); }}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
),
|
|
}),
|
|
];
|
|
|
|
const table = useReactTable({ data: users || [], columns, getCoreRowModel: getCoreRowModel() });
|
|
|
|
return (
|
|
<div>
|
|
<Dialog
|
|
isOpen={isFormOpen}
|
|
onClose={() => { setIsFormOpen(false); setSelectedUser(null); }}
|
|
title={selectedUser ? 'Edit User' : 'Create New User'}
|
|
showCloseButton={false}
|
|
>
|
|
<UserForm user={selectedUser} onClose={() => { setIsFormOpen(false); setSelectedUser(null); }} />
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
isOpen={isDeleteConfirmOpen}
|
|
onClose={() => { setIsDeleteConfirmOpen(false); setSelectedUser(null); }}
|
|
title="Confirm Deletion"
|
|
description={`Are you sure you want to delete the user ${selectedUser?.email}? This action cannot be undone.`}
|
|
confirmText="Yes, Delete User"
|
|
onConfirm={() => selectedUser && deleteMutation.mutate(selectedUser.email)}
|
|
/>
|
|
|
|
<div className="flex justify-between items-center mb-8">
|
|
<h1 className="text-3xl font-bold font-mono">User Management</h1>
|
|
<Button onClick={() => { setSelectedUser(null); setIsFormOpen(true); }}>
|
|
<UserPlus className="h-4 w-4 mr-2" />
|
|
New User
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center p-12"><Loader2 className="h-8 w-8 animate-spin text-green-accent" /></div>
|
|
) : isError ? (
|
|
<div className="flex items-center text-red-400 p-8"><AlertTriangle className="h-5 w-5 mr-3" /><span>Failed to load users.</span></div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-neutral-800/50">
|
|
{table.getHeaderGroups().map(headerGroup => (
|
|
<tr key={headerGroup.id}>
|
|
{headerGroup.headers.map(header => (
|
|
<th key={header.id} className="p-4 text-left font-mono text-neutral-400 uppercase tracking-wider">
|
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{table.getRowModel().rows.map(row => (
|
|
<tr key={row.id} className="border-b border-neutral-800 last:border-b-0 hover:bg-neutral-800/30 transition-colors">
|
|
{row.getVisibleCells().map(cell => (
|
|
<td key={cell.id} className="p-4">
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UserManagementPage;
|