410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import { useState } from "react"
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
import { useNavigate } from "react-router-dom"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Search, Download, Eye, UserPlus, Trash2, Key, Upload } from "lucide-react"
|
|
import { userService } from "@/services"
|
|
import { toast } from "sonner"
|
|
import { format } from "date-fns"
|
|
import type { ApiError } from "@/types/error.types"
|
|
|
|
interface User {
|
|
id: string
|
|
email: string
|
|
firstName: string
|
|
lastName: string
|
|
role: string
|
|
isActive: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
export default function UsersPage() {
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const [page, setPage] = useState(1)
|
|
const [limit] = useState(20)
|
|
const [search, setSearch] = useState("")
|
|
const [roleFilter, setRoleFilter] = useState<string>("all")
|
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
|
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
|
|
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
|
const [importFile, setImportFile] = useState<File | null>(null)
|
|
|
|
const { data: usersData, isLoading } = useQuery({
|
|
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
|
|
queryFn: async () => {
|
|
const params: Record<string, string | number | boolean> = { page, limit }
|
|
if (search) params.search = search
|
|
if (roleFilter !== 'all') params.role = roleFilter
|
|
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
|
|
return await userService.getUsers(params)
|
|
},
|
|
})
|
|
|
|
const deleteUserMutation = useMutation({
|
|
mutationFn: ({ id, hard }: { id: string; hard: boolean }) =>
|
|
userService.deleteUser(id, hard),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
|
toast.success("User deleted successfully")
|
|
setDeleteDialogOpen(false)
|
|
},
|
|
onError: (error) => {
|
|
const apiError = error as ApiError
|
|
toast.error(apiError.response?.data?.message || "Failed to delete user")
|
|
},
|
|
})
|
|
|
|
const resetPasswordMutation = useMutation({
|
|
mutationFn: (id: string) => userService.resetPassword(id),
|
|
onSuccess: (data) => {
|
|
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
|
|
setResetPasswordDialogOpen(false)
|
|
},
|
|
onError: (error) => {
|
|
const apiError = error as ApiError
|
|
toast.error(apiError.response?.data?.message || "Failed to reset password")
|
|
},
|
|
})
|
|
|
|
const importUsersMutation = useMutation({
|
|
mutationFn: (file: File) => userService.importUsers(file),
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
|
toast.success(`Imported ${data.imported} users. ${data.failed} failed.`)
|
|
setImportDialogOpen(false)
|
|
setImportFile(null)
|
|
},
|
|
onError: (error) => {
|
|
const apiError = error as ApiError
|
|
toast.error(apiError.response?.data?.message || "Failed to import users")
|
|
},
|
|
})
|
|
|
|
const handleExport = async () => {
|
|
try {
|
|
const blob = await userService.exportUsers('csv')
|
|
const url = window.URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `users-${new Date().toISOString()}.csv`
|
|
a.click()
|
|
toast.success("Users exported successfully")
|
|
} catch (error) {
|
|
const apiError = error as ApiError
|
|
toast.error(apiError.response?.data?.message || "Failed to export users")
|
|
}
|
|
}
|
|
|
|
const handleDelete = () => {
|
|
if (selectedUser) {
|
|
deleteUserMutation.mutate({ id: selectedUser.id, hard: false })
|
|
}
|
|
}
|
|
|
|
const handleResetPassword = () => {
|
|
if (selectedUser) {
|
|
resetPasswordMutation.mutate(selectedUser.id)
|
|
}
|
|
}
|
|
|
|
const handleImport = () => {
|
|
if (importFile) {
|
|
importUsersMutation.mutate(importFile)
|
|
}
|
|
}
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) {
|
|
setImportFile(file)
|
|
}
|
|
}
|
|
|
|
const getRoleBadgeVariant = (role: string) => {
|
|
switch (role) {
|
|
case 'ADMIN':
|
|
return 'destructive'
|
|
case 'USER':
|
|
return 'default'
|
|
case 'VIEWER':
|
|
return 'secondary'
|
|
default:
|
|
return 'outline'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-3xl font-bold">Users Management</h2>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setImportDialogOpen(true)}>
|
|
<Upload className="w-4 h-4 mr-2" />
|
|
Import Users
|
|
</Button>
|
|
<Button>
|
|
<UserPlus className="w-4 h-4 mr-2" />
|
|
Add User
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>All Users</CardTitle>
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search users..."
|
|
className="pl-10 w-64"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue placeholder="All Roles" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Roles</SelectItem>
|
|
<SelectItem value="ADMIN">Admin</SelectItem>
|
|
<SelectItem value="USER">User</SelectItem>
|
|
<SelectItem value="VIEWER">Viewer</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue placeholder="All Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Status</SelectItem>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button variant="outline" onClick={handleExport}>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Export
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="text-center py-8">Loading users...</div>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Created At</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{usersData?.data?.map((user: User) => (
|
|
<TableRow key={user.id}>
|
|
<TableCell className="font-medium">{user.email}</TableCell>
|
|
<TableCell>{user.firstName} {user.lastName}</TableCell>
|
|
<TableCell>
|
|
<Badge variant={getRoleBadgeVariant(user.role)}>
|
|
{user.role}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
|
{user.isActive ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate(`/admin/users/${user.id}`)}
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setSelectedUser(user)
|
|
setResetPasswordDialogOpen(true)
|
|
}}
|
|
>
|
|
<Key className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setSelectedUser(user)
|
|
setDeleteDialogOpen(true)
|
|
}}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
{usersData?.data?.length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No users found
|
|
</div>
|
|
)}
|
|
{usersData && usersData.total > limit && (
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
Showing {(page - 1) * limit + 1} to {Math.min(page * limit, usersData.total)} of {usersData.total} users
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => p + 1)}
|
|
disabled={page * limit >= usersData.total}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Delete Dialog */}
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete User</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete {selectedUser?.email}? This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDelete}>
|
|
Delete
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Reset Password Dialog */}
|
|
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Reset Password</DialogTitle>
|
|
<DialogDescription>
|
|
Reset password for {selectedUser?.email}? A temporary password will be generated.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setResetPasswordDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleResetPassword}>
|
|
Reset Password
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Import Users Dialog */}
|
|
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Import Users</DialogTitle>
|
|
<DialogDescription>
|
|
Upload a CSV file with user data. The file should contain columns: email, firstName, lastName, role (optional).
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="import-file">CSV File</Label>
|
|
<Input
|
|
id="import-file"
|
|
type="file"
|
|
accept=".csv"
|
|
onChange={handleFileChange}
|
|
/>
|
|
{importFile && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Selected: {importFile.name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => {
|
|
setImportDialogOpen(false)
|
|
setImportFile(null)
|
|
}}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleImport} disabled={!importFile || importUsersMutation.isPending}>
|
|
{importUsersMutation.isPending ? "Importing..." : "Import"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|