diff --git a/frontend/app/(protected)/admin/audit-logs/page.tsx b/frontend/app/(protected)/admin/audit-logs/page.tsx index 6a01ce1b..ca6b16b0 100644 --- a/frontend/app/(protected)/admin/audit-logs/page.tsx +++ b/frontend/app/(protected)/admin/audit-logs/page.tsx @@ -1,90 +1,269 @@ -import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; -import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table"; -// Define the type for the audit log data interface AuditLog { id: string; - ip_address: string; - timestamp: string; - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - path: string; - status_code: number; - user_agent: string; + routePath: string; + httpMethod: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + ipAddress: string; + statusCode: number | null; + createdAt: string; } -async function getAuditLogs(): Promise { - // This is a server component, so we can fetch data directly. - // In a real app, you'd fetch from your API endpoint. - // For this example, we'll use mock data. - try { - // const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/audit`); - // if (!res.ok) { - // throw new Error('Failed to fetch audit logs'); - // } - // const data = await res.json(); - // return data; - - // Mock data for demonstration - const mockData: AuditLog[] = [ - { id: '1', ip_address: '192.168.1.1', timestamp: new Date().toISOString(), method: 'GET', path: '/api/users', status_code: 200, user_agent: 'Mozilla/5.0' }, - { id: '2', ip_address: '10.0.0.1', timestamp: new Date(Date.now() - 1000 * 60 * 2).toISOString(), method: 'POST', path: '/api/auth/login', status_code: 200, user_agent: 'Chrome/91.0' }, - { id: '3', ip_address: '172.16.0.1', timestamp: new Date(Date.now() - 1000 * 60 * 5).toISOString(), method: 'GET', path: '/api/files/123', status_code: 404, user_agent: 'curl/7.64.1' }, - { id: '4', ip_address: '192.168.1.2', timestamp: new Date(Date.now() - 1000 * 60 * 10).toISOString(), method: 'DELETE', path: '/api/files/456', status_code: 204, user_agent: 'Mozilla/5.0' }, - ]; - return mockData; - } catch (error) { - console.error(error); - return []; - } +interface PaginatedAccessLogs { + data: AuditLog[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +const PAGE_SIZE = 20; + +function getAuthHeaders(): HeadersInit { + const token = + typeof window !== "undefined" ? localStorage.getItem("auth-token") : null; + + return token ? { Authorization: `Bearer ${token}` } : {}; } -export default async function AdminAuditLogsPage() { - const auditLogs = await getAuditLogs(); +export default function AdminAuditLogsPage() { + const [auditLogs, setAuditLogs] = useState([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [userId, setUserId] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [appliedFilters, setAppliedFilters] = useState({ + userId: "", + startDate: "", + endDate: "", + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchAuditLogs = useCallback(async () => { + setLoading(true); + setError(null); + + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + }); + if (appliedFilters.userId) params.set("userId", appliedFilters.userId); + if (appliedFilters.startDate) params.set("startDate", appliedFilters.startDate); + if (appliedFilters.endDate) params.set("endDate", appliedFilters.endDate); + + try { + const response = await fetch(`${API_BASE}/admin/access-logs?${params}`, { + headers: getAuthHeaders(), + }); + if (!response.ok) { + throw new Error(`Failed to load audit logs (${response.status})`); + } + + const result = (await response.json()) as PaginatedAccessLogs; + setAuditLogs(Array.isArray(result.data) ? result.data : []); + setTotal(result.total ?? 0); + setTotalPages(result.totalPages ?? 0); + } catch (fetchError) { + setAuditLogs([]); + setTotal(0); + setTotalPages(0); + setError( + fetchError instanceof Error + ? fetchError.message + : "Failed to load audit logs", + ); + } finally { + setLoading(false); + } + }, [appliedFilters, page]); + + useEffect(() => { + fetchAuditLogs(); + }, [fetchAuditLogs]); + + function applyFilters() { + setPage(1); + setAppliedFilters({ + userId: userId.trim(), + startDate, + endDate, + }); + } + + function resetFilters() { + setUserId(""); + setStartDate(""); + setEndDate(""); + setPage(1); + setAppliedFilters({ userId: "", startDate: "", endDate: "" }); + } return ( -
-

HTTP Access Logs

+
+
+

HTTP Access Logs

+

+ Showing at most {PAGE_SIZE} access logs per page. +

+
+ +
+
+ + setUserId(event.target.value)} + className="rounded border border-gray-300 px-3 py-2 text-sm" + /> +
+
+ + setStartDate(event.target.value)} + className="rounded border border-gray-300 px-3 py-2 text-sm" + /> +
+
+ + setEndDate(event.target.value)} + className="rounded border border-gray-300 px-3 py-2 text-sm" + /> +
+
+ + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + Audit Logs - A record of all HTTP requests made to the server. + {total} {total === 1 ? "request" : "requests"} found - - - - IP Address - Timestamp - Method - Path - Status - User Agent - - - - {auditLogs.map((log) => ( - - {log.ip_address} - {new Date(log.timestamp).toLocaleString()} - {log.method} - {log.path} - {log.status_code} - {log.user_agent} - - ))} - {auditLogs.length === 0 && ( - - - No audit logs found. - - - )} - -
+ {loading ? ( +

+ Loading audit logs… +

+ ) : auditLogs.length === 0 ? ( +

+ No audit logs found. +

+ ) : ( +
+ + + + IP Address + Timestamp + Method + Path + Status + + + + {auditLogs.map((log) => ( + + {log.ipAddress} + {new Date(log.createdAt).toLocaleString()} + {log.httpMethod} + {log.routePath} + {log.statusCode ?? "—"} + + ))} + +
+
+ )} + + {totalPages > 1 ? ( + + ) : null}
-
+ ); -} \ No newline at end of file +} diff --git a/frontend/test-utils/admin-audit-logs.test.tsx b/frontend/test-utils/admin-audit-logs.test.tsx new file mode 100644 index 00000000..89e4b3c3 --- /dev/null +++ b/frontend/test-utils/admin-audit-logs.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import AdminAuditLogsPage from "@/app/(protected)/admin/audit-logs/page"; + +const mockFetch = jest.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: jest.fn(() => "admin-token"), + }, + }); + global.fetch = mockFetch as unknown as typeof fetch; +}); + +function response(body: unknown): Response { + return { + ok: true, + status: 200, + json: () => Promise.resolve(body), + } as Response; +} + +function renderPage() { + return render(); +} + +describe("AdminAuditLogsPage", () => { + it("requests at most 20 records and renders only the returned page", async () => { + mockFetch.mockResolvedValue( + response({ + data: Array.from({ length: 20 }, (_, index) => ({ + id: `log-${index}`, + routePath: `/api/documents/${index}`, + httpMethod: "GET", + ipAddress: "192.0.2.1", + statusCode: 200, + createdAt: "2026-08-26T12:00:00.000Z", + })), + total: 41, + page: 1, + limit: 20, + totalPages: 3, + }), + ); + + renderPage(); + + await waitFor(() => expect(screen.getByText("/api/documents/19")).toBeInTheDocument()); + expect(screen.queryByText("/api/documents/20")).not.toBeInTheDocument(); + expect(String(mockFetch.mock.calls[0][0])).toContain("page=1"); + expect(String(mockFetch.mock.calls[0][0])).toContain("limit=20"); + expect(screen.getByRole("navigation", { name: "Audit log pagination" })).toBeInTheDocument(); + }); + + it("requests the next bounded page when pagination advances", async () => { + mockFetch + .mockResolvedValueOnce( + response({ + data: [{ + id: "log-1", + routePath: "/first-page", + httpMethod: "GET", + ipAddress: "192.0.2.1", + statusCode: 200, + createdAt: "2026-08-26T12:00:00.000Z", + }], + total: 21, + page: 1, + limit: 20, + totalPages: 2, + }), + ) + .mockResolvedValueOnce( + response({ + data: [{ + id: "log-21", + routePath: "/second-page", + httpMethod: "POST", + ipAddress: "192.0.2.2", + statusCode: 201, + createdAt: "2026-08-26T12:01:00.000Z", + }], + total: 21, + page: 2, + limit: 20, + totalPages: 2, + }), + ); + + renderPage(); + await waitFor(() => expect(screen.getByText("/first-page")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => expect(screen.getByText("/second-page")).toBeInTheDocument()); + + expect(String(mockFetch.mock.calls[1][0])).toContain("page=2"); + expect(String(mockFetch.mock.calls[1][0])).toContain("limit=20"); + }); +});