feat(audit-logging): Implement Edge-compatible audit logging utility and safe logging module
- Added `auditLogEdge.js` for Edge Runtime compatible audit logging, including console logging and API fallback. - Introduced `auditLogSafe.js` for safe audit logging without direct database imports, ensuring compatibility across runtimes. - Enhanced `auth.js` to integrate safe audit logging for login actions, including success and failure cases. - Created middleware `auditLog.js` to facilitate audit logging for API routes with predefined configurations. - Updated `middleware.js` to allow API route access without authentication checks. - Added tests for audit logging functionality and Edge compatibility in `test-audit-logging.mjs` and `test-edge-compatibility.mjs`. - Implemented safe audit logging tests in `test-safe-audit-logging.mjs` to verify functionality across environments.
This commit is contained in:
424
src/components/AuditLogViewer.js
Normal file
424
src/components/AuditLogViewer.js
Normal file
@@ -0,0 +1,424 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function AuditLogViewer() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
action: "",
|
||||
resourceType: "",
|
||||
userId: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const [actionTypes, setActionTypes] = useState([]);
|
||||
const [resourceTypes, setResourceTypes] = useState([]);
|
||||
|
||||
const fetchAuditLogs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value !== "") {
|
||||
queryParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
queryParams.append("includeStats", "true");
|
||||
|
||||
const response = await fetch(`/api/audit-logs?${queryParams}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch audit logs");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setLogs(result.data);
|
||||
setStats(result.stats);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Set available filter options
|
||||
setActionTypes([
|
||||
"login",
|
||||
"logout",
|
||||
"login_failed",
|
||||
"project_create",
|
||||
"project_update",
|
||||
"project_delete",
|
||||
"project_view",
|
||||
"task_create",
|
||||
"task_update",
|
||||
"task_delete",
|
||||
"task_status_change",
|
||||
"project_task_create",
|
||||
"project_task_update",
|
||||
"project_task_delete",
|
||||
"contract_create",
|
||||
"contract_update",
|
||||
"contract_delete",
|
||||
"note_create",
|
||||
"note_update",
|
||||
"note_delete",
|
||||
"user_create",
|
||||
"user_update",
|
||||
"user_delete",
|
||||
"user_role_change",
|
||||
]);
|
||||
|
||||
setResourceTypes([
|
||||
"project",
|
||||
"task",
|
||||
"project_task",
|
||||
"contract",
|
||||
"note",
|
||||
"user",
|
||||
"session",
|
||||
"system",
|
||||
]);
|
||||
|
||||
fetchAuditLogs();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
offset: 0, // Reset pagination when filters change
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchAuditLogs();
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
action: "",
|
||||
resourceType: "",
|
||||
userId: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
offset: prev.offset + prev.limit,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filters.offset > 0) {
|
||||
fetchAuditLogs();
|
||||
}
|
||||
}, [filters.offset]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
try {
|
||||
return format(new Date(timestamp), "yyyy-MM-dd HH:mm:ss");
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (action) => {
|
||||
const colorMap = {
|
||||
login: "text-green-600",
|
||||
logout: "text-blue-600",
|
||||
login_failed: "text-red-600",
|
||||
create: "text-green-600",
|
||||
update: "text-yellow-600",
|
||||
delete: "text-red-600",
|
||||
view: "text-gray-600",
|
||||
};
|
||||
|
||||
for (const [key, color] of Object.entries(colorMap)) {
|
||||
if (action.includes(key)) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
return "text-gray-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Audit Logs</h1>
|
||||
<p className="text-gray-600">View system activity and user actions</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filters</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Action
|
||||
</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange("action", e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{actionTypes.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{action.replace(/_/g, " ").toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Resource Type
|
||||
</label>
|
||||
<select
|
||||
value={filters.resourceType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("resourceType", e.target.value)
|
||||
}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All Resources</option>
|
||||
{resourceTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type.replace(/_/g, " ").toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
User ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.userId}
|
||||
onChange={(e) => handleFilterChange("userId", e.target.value)}
|
||||
placeholder="Enter user ID"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => handleFilterChange("startDate", e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Limit
|
||||
</label>
|
||||
<select
|
||||
value={filters.limit}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("limit", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Total Events</h3>
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Top Action</h3>
|
||||
<p className="text-sm font-medium">
|
||||
{stats.actionBreakdown[0]?.action || "N/A"}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{stats.actionBreakdown[0]?.count || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Active Users</h3>
|
||||
<p className="text-2xl font-bold text-purple-600">
|
||||
{stats.userBreakdown.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Resource Types</h3>
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
{stats.resourceBreakdown.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Logs Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{log.user_name || "Anonymous"}
|
||||
</div>
|
||||
<div className="text-gray-500">{log.user_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span
|
||||
className={`font-medium ${getActionColor(log.action)}`}
|
||||
>
|
||||
{log.action.replace(/_/g, " ").toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{log.resource_type || "N/A"}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
ID: {log.resource_id || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{log.ip_address || "Unknown"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{log.details && (
|
||||
<details className="cursor-pointer">
|
||||
<summary className="text-blue-600 hover:text-blue-800">
|
||||
View Details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-gray-100 p-2 rounded overflow-auto max-w-md">
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{logs.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No audit logs found matching your criteria.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {filters.offset + 1} to {filters.offset + logs.length}{" "}
|
||||
results
|
||||
</div>
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loading || logs.length < filters.limit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user