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:
Chop
2025-07-09 23:08:16 +02:00
parent 90875db28b
commit b1a78bf7a8
20 changed files with 2943 additions and 130 deletions

View 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>
);
}