Dissolve/admin-frontend/src/pages/UserManagementPage.tsx
2026-07-15 20:35:25 +05:30

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;