feat(activity-log): Implement dynamic audit log page with filtering and export
This commit is contained in:
parent
a1c9b689d5
commit
ba209593f5
|
|
@ -1,3 +1,5 @@
|
|||
import { useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
|
@ -10,14 +12,60 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Search, Download, Eye, MoreVertical } from "lucide-react"
|
||||
import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { auditService } from "@/services"
|
||||
import { format } from "date-fns"
|
||||
|
||||
export default function ActivityLogPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit] = useState(20)
|
||||
const [search, setSearch] = useState("")
|
||||
const [actionFilter, setActionFilter] = useState("")
|
||||
const [resourceTypeFilter, setResourceTypeFilter] = useState("")
|
||||
|
||||
const { data: auditData, isLoading } = useQuery({
|
||||
queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter],
|
||||
queryFn: async () => {
|
||||
const params: any = { page, limit }
|
||||
if (search) params.search = search
|
||||
if (actionFilter) params.action = actionFilter
|
||||
if (resourceTypeFilter) params.resourceType = resourceTypeFilter
|
||||
return await auditService.getAuditLogs(params)
|
||||
},
|
||||
})
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await auditService.exportAuditLogs({ format: 'csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `activity-log-${format(new Date(), 'yyyy-MM-dd')}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getActionBadgeColor = (action: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
Create: "bg-blue-500",
|
||||
Update: "bg-green-500",
|
||||
Delete: "bg-red-500",
|
||||
Login: "bg-purple-500",
|
||||
Logout: "bg-gray-500",
|
||||
}
|
||||
return colors[action] || "bg-gray-500"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold">Activity Log</h2>
|
||||
<Button>
|
||||
<Button onClick={handleExport}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Log
|
||||
</Button>
|
||||
|
|
@ -33,109 +81,113 @@ export default function ActivityLogPage() {
|
|||
<Input
|
||||
placeholder="Search activity..."
|
||||
className="pl-10 w-64"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select className="px-3 py-2 border rounded-md text-sm">
|
||||
<option>All Actions</option>
|
||||
<option>Create</option>
|
||||
<option>Update</option>
|
||||
<option>Delete</option>
|
||||
<option>Login</option>
|
||||
<option>Logout</option>
|
||||
<select
|
||||
className="px-3 py-2 border rounded-md text-sm"
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="Create">Create</option>
|
||||
<option value="Update">Update</option>
|
||||
<option value="Delete">Delete</option>
|
||||
<option value="Login">Login</option>
|
||||
<option value="Logout">Logout</option>
|
||||
</select>
|
||||
<select className="px-3 py-2 border rounded-md text-sm">
|
||||
<option>All Users</option>
|
||||
<option>Admin</option>
|
||||
<option>Manager</option>
|
||||
<option>User</option>
|
||||
<select
|
||||
className="px-3 py-2 border rounded-md text-sm"
|
||||
value={resourceTypeFilter}
|
||||
onChange={(e) => setResourceTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All Resources</option>
|
||||
<option value="Client">Client</option>
|
||||
<option value="Subscription">Subscription</option>
|
||||
<option value="User">User</option>
|
||||
<option value="System">System</option>
|
||||
</select>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">Loading activity logs...</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Log ID</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Resource</TableHead>
|
||||
<TableHead>Resource ID</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">LOG001</TableCell>
|
||||
<TableCell>john.smith@example.com</TableCell>
|
||||
{auditData?.data?.map((log: any) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">{log.id}</TableCell>
|
||||
<TableCell>{log.userId || 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-blue-500">Create</Badge>
|
||||
<Badge className={getActionBadgeColor(log.action)}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{log.resourceType}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
|
||||
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(log.timestamp || log.createdAt), 'MMM dd, yyyy HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Created new client record</TableCell>
|
||||
<TableCell>192.168.1.1</TableCell>
|
||||
<TableCell>2024-01-15 10:30:45</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">LOG002</TableCell>
|
||||
<TableCell>jane.doe@example.com</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-500">Update</Badge>
|
||||
</TableCell>
|
||||
<TableCell>Subscription</TableCell>
|
||||
<TableCell>Updated subscription status</TableCell>
|
||||
<TableCell>192.168.1.2</TableCell>
|
||||
<TableCell>2024-01-15 09:15:22</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">LOG003</TableCell>
|
||||
<TableCell>admin@example.com</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-purple-500">Login</Badge>
|
||||
</TableCell>
|
||||
<TableCell>System</TableCell>
|
||||
<TableCell>User logged in successfully</TableCell>
|
||||
<TableCell>192.168.1.3</TableCell>
|
||||
<TableCell>2024-01-15 08:00:00</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{auditData?.data?.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No activity logs found
|
||||
</div>
|
||||
)}
|
||||
{auditData && auditData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {auditData.page} of {auditData.totalPages} ({auditData.total} total)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(auditData.totalPages, p + 1))}
|
||||
disabled={page === auditData.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ const apiClient: AxiosInstance = axios.create({
|
|||
},
|
||||
withCredentials: true, // Send cookies with requests
|
||||
timeout: 30000, // 30 second timeout
|
||||
paramsSerializer: {
|
||||
serialize: (params) => {
|
||||
// Custom serializer to preserve number types
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
})
|
||||
return searchParams.toString()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Request interceptor - Add auth token
|
||||
|
|
|
|||
|
|
@ -41,7 +41,14 @@ class UserService {
|
|||
* Get paginated list of users
|
||||
*/
|
||||
async getUsers(params?: GetUsersParams): Promise<PaginatedResponse<User>> {
|
||||
const response = await apiClient.get<PaginatedResponse<User>>('/admin/users', { params })
|
||||
// Ensure numeric params are sent as numbers, not strings
|
||||
const queryParams = params ? {
|
||||
...params,
|
||||
page: params.page ? Number(params.page) : undefined,
|
||||
limit: params.limit ? Number(params.limit) : undefined,
|
||||
} : undefined
|
||||
|
||||
const response = await apiClient.get<PaginatedResponse<User>>('/admin/users', { params: queryParams })
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user