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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -10,14 +12,60 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-3xl font-bold">Activity Log</h2>
|
<h2 className="text-3xl font-bold">Activity Log</h2>
|
||||||
<Button>
|
<Button onClick={handleExport}>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export Log
|
Export Log
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -33,109 +81,113 @@ export default function ActivityLogPage() {
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search activity..."
|
placeholder="Search activity..."
|
||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Actions</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>Create</option>
|
value={actionFilter}
|
||||||
<option>Update</option>
|
onChange={(e) => setActionFilter(e.target.value)}
|
||||||
<option>Delete</option>
|
>
|
||||||
<option>Login</option>
|
<option value="">All Actions</option>
|
||||||
<option>Logout</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>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Users</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>Admin</option>
|
value={resourceTypeFilter}
|
||||||
<option>Manager</option>
|
onChange={(e) => setResourceTypeFilter(e.target.value)}
|
||||||
<option>User</option>
|
>
|
||||||
|
<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>
|
</select>
|
||||||
<Button variant="outline">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{isLoading ? (
|
||||||
<TableHeader>
|
<div className="text-center py-8">Loading activity logs...</div>
|
||||||
<TableRow>
|
) : (
|
||||||
<TableHead>Log ID</TableHead>
|
<>
|
||||||
<TableHead>User</TableHead>
|
<Table>
|
||||||
<TableHead>Action</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Entity</TableHead>
|
<TableRow>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Log ID</TableHead>
|
||||||
<TableHead>IP Address</TableHead>
|
<TableHead>User</TableHead>
|
||||||
<TableHead>Timestamp</TableHead>
|
<TableHead>Action</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>Resource</TableHead>
|
||||||
</TableRow>
|
<TableHead>Resource ID</TableHead>
|
||||||
</TableHeader>
|
<TableHead>IP Address</TableHead>
|
||||||
<TableBody>
|
<TableHead>Timestamp</TableHead>
|
||||||
<TableRow>
|
<TableHead>Actions</TableHead>
|
||||||
<TableCell className="font-medium">LOG001</TableCell>
|
</TableRow>
|
||||||
<TableCell>john.smith@example.com</TableCell>
|
</TableHeader>
|
||||||
<TableCell>
|
<TableBody>
|
||||||
<Badge className="bg-blue-500">Create</Badge>
|
{auditData?.data?.map((log: any) => (
|
||||||
</TableCell>
|
<TableRow key={log.id}>
|
||||||
<TableCell>Client</TableCell>
|
<TableCell className="font-medium">{log.id}</TableCell>
|
||||||
<TableCell>Created new client record</TableCell>
|
<TableCell>{log.userId || 'N/A'}</TableCell>
|
||||||
<TableCell>192.168.1.1</TableCell>
|
<TableCell>
|
||||||
<TableCell>2024-01-15 10:30:45</TableCell>
|
<Badge className={getActionBadgeColor(log.action)}>
|
||||||
<TableCell>
|
{log.action}
|
||||||
<div className="flex items-center gap-2">
|
</Badge>
|
||||||
<Button variant="ghost" size="icon">
|
</TableCell>
|
||||||
<Eye className="w-4 h-4" />
|
<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>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
<Button variant="ghost" size="icon">
|
<Button
|
||||||
<MoreVertical className="w-4 h-4" />
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
</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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,18 @@ const apiClient: AxiosInstance = axios.create({
|
||||||
},
|
},
|
||||||
withCredentials: true, // Send cookies with requests
|
withCredentials: true, // Send cookies with requests
|
||||||
timeout: 30000, // 30 second timeout
|
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
|
// Request interceptor - Add auth token
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,14 @@ class UserService {
|
||||||
* Get paginated list of users
|
* Get paginated list of users
|
||||||
*/
|
*/
|
||||||
async getUsers(params?: GetUsersParams): Promise<PaginatedResponse<User>> {
|
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
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user