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:
55
src/app/admin/audit-logs/page.js
Normal file
55
src/app/admin/audit-logs/page.js
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import AuditLogViewer from "@/components/AuditLogViewer";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return; // Still loading
|
||||
|
||||
if (!session) {
|
||||
router.push("/auth/signin");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow admins and project managers to view audit logs
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !["admin", "project_manager"].includes(session.user.role)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
You don't have permission to view this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<AuditLogViewer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/app/api/audit-logs/log/route.js
Normal file
49
src/app/api/audit-logs/log/route.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { logAuditEvent } from "@/lib/auditLog";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
const {
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
} = data;
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{ error: "Action is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Log the audit event
|
||||
await logAuditEvent({
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Audit log API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/app/api/audit-logs/route.js
Normal file
67
src/app/api/audit-logs/route.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAuditLogs, getAuditLogStats } from "@/lib/auditLog";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins and project managers can view audit logs
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Parse query parameters
|
||||
const filters = {
|
||||
userId: searchParams.get("userId") || null,
|
||||
action: searchParams.get("action") || null,
|
||||
resourceType: searchParams.get("resourceType") || null,
|
||||
resourceId: searchParams.get("resourceId") || null,
|
||||
startDate: searchParams.get("startDate") || null,
|
||||
endDate: searchParams.get("endDate") || null,
|
||||
limit: parseInt(searchParams.get("limit")) || 100,
|
||||
offset: parseInt(searchParams.get("offset")) || 0,
|
||||
orderBy: searchParams.get("orderBy") || "timestamp",
|
||||
orderDirection: searchParams.get("orderDirection") || "DESC",
|
||||
};
|
||||
|
||||
// Get audit logs
|
||||
const logs = await getAuditLogs(filters);
|
||||
|
||||
// Get statistics if requested
|
||||
const includeStats = searchParams.get("includeStats") === "true";
|
||||
let stats = null;
|
||||
|
||||
if (includeStats) {
|
||||
stats = await getAuditLogStats({
|
||||
startDate: filters.startDate,
|
||||
endDate: filters.endDate,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
stats,
|
||||
filters: {
|
||||
...filters,
|
||||
total: logs.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Audit logs API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/api/audit-logs/stats/route.js
Normal file
41
src/app/api/audit-logs/stats/route.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAuditLogStats } from "@/lib/auditLog";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins and project managers can view audit log statistics
|
||||
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const filters = {
|
||||
startDate: searchParams.get("startDate") || null,
|
||||
endDate: searchParams.get("endDate") || null,
|
||||
};
|
||||
|
||||
const stats = await getAuditLogStats(filters);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Audit log stats API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
async function createNoteHandler(req) {
|
||||
const { project_id, task_id, note } = await req.json();
|
||||
@@ -10,12 +18,26 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`
|
||||
).run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||
)
|
||||
.run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||
|
||||
// Log note creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_CREATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
result.lastInsertRowid.toString(),
|
||||
req.session,
|
||||
{
|
||||
noteData: { project_id, task_id, note_length: note.length },
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -27,11 +49,30 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNoteHandler(_, { params }) {
|
||||
async function deleteNoteHandler(req, { params }) {
|
||||
const { id } = params;
|
||||
|
||||
// Get note data before deletion for audit log
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
|
||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
||||
|
||||
// Log note deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_DELETE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
id,
|
||||
req.session,
|
||||
{
|
||||
deletedNote: {
|
||||
project_id: note?.project_id,
|
||||
task_id: note?.task_id,
|
||||
note_length: note?.note?.length || 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -43,12 +84,36 @@ async function updateNoteHandler(req, { params }) {
|
||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get original note for audit log
|
||||
const originalNote = db
|
||||
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||
.get(noteId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE notes SET note = ? WHERE note_id = ?
|
||||
`
|
||||
).run(note, noteId);
|
||||
|
||||
// Log note update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
noteId,
|
||||
req.session,
|
||||
{
|
||||
originalNote: {
|
||||
note_length: originalNote?.note?.length || 0,
|
||||
project_id: originalNote?.project_id,
|
||||
task_id: originalNote?.task_id,
|
||||
},
|
||||
updatedNote: {
|
||||
note_length: note.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getProjectById,
|
||||
updateProject,
|
||||
@@ -6,6 +9,11 @@ import {
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
@@ -18,6 +26,16 @@ async function getProjectHandler(req, { params }) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Log project view
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.session,
|
||||
{ project_name: project.project_name }
|
||||
);
|
||||
|
||||
return NextResponse.json(project);
|
||||
}
|
||||
|
||||
@@ -28,16 +46,54 @@ async function updateProjectHandler(req, { params }) {
|
||||
// Get user ID from authenticated request
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Get original project data for audit log
|
||||
const originalProject = getProjectById(parseInt(id));
|
||||
|
||||
updateProject(parseInt(id), data, userId);
|
||||
|
||||
// Return the updated project
|
||||
// Get updated project
|
||||
const updatedProject = getProjectById(parseInt(id));
|
||||
|
||||
// Log project update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.session,
|
||||
{
|
||||
originalData: originalProject,
|
||||
updatedData: data,
|
||||
changedFields: Object.keys(data),
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(updatedProject);
|
||||
}
|
||||
|
||||
async function deleteProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
// Get project data before deletion for audit log
|
||||
const project = getProjectById(parseInt(id));
|
||||
|
||||
deleteProject(parseInt(id));
|
||||
|
||||
// Log project deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_DELETE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.session,
|
||||
{
|
||||
deletedProject: {
|
||||
project_name: project?.project_name,
|
||||
project_number: project?.project_number,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getAllProjects,
|
||||
createProject,
|
||||
@@ -6,6 +9,11 @@ import {
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
@@ -30,6 +38,19 @@ async function getProjectsHandler(req) {
|
||||
projects = getAllProjects(contractId);
|
||||
}
|
||||
|
||||
// Log project list access
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
null, // No specific project ID for list view
|
||||
req.session,
|
||||
{
|
||||
filters: { contractId, assignedTo, createdBy },
|
||||
resultCount: projects.length,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(projects);
|
||||
}
|
||||
|
||||
@@ -40,9 +61,27 @@ async function createProjectHandler(req) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
const result = createProject(data, userId);
|
||||
const projectId = result.lastInsertRowid;
|
||||
|
||||
// Log project creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_CREATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
projectId.toString(),
|
||||
req.session,
|
||||
{
|
||||
projectData: {
|
||||
project_name: data.project_name,
|
||||
project_number: data.project_number,
|
||||
contract_id: data.contract_id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
projectId: result.lastInsertRowid,
|
||||
projectId: projectId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
424
src/lib/auditLog.js
Normal file
424
src/lib/auditLog.js
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Audit log actions - standardized action types
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
// Authentication
|
||||
LOGIN: "login",
|
||||
LOGOUT: "logout",
|
||||
LOGIN_FAILED: "login_failed",
|
||||
|
||||
// Projects
|
||||
PROJECT_CREATE: "project_create",
|
||||
PROJECT_UPDATE: "project_update",
|
||||
PROJECT_DELETE: "project_delete",
|
||||
PROJECT_VIEW: "project_view",
|
||||
|
||||
// Tasks
|
||||
TASK_CREATE: "task_create",
|
||||
TASK_UPDATE: "task_update",
|
||||
TASK_DELETE: "task_delete",
|
||||
TASK_STATUS_CHANGE: "task_status_change",
|
||||
|
||||
// Project Tasks
|
||||
PROJECT_TASK_CREATE: "project_task_create",
|
||||
PROJECT_TASK_UPDATE: "project_task_update",
|
||||
PROJECT_TASK_DELETE: "project_task_delete",
|
||||
PROJECT_TASK_STATUS_CHANGE: "project_task_status_change",
|
||||
|
||||
// Contracts
|
||||
CONTRACT_CREATE: "contract_create",
|
||||
CONTRACT_UPDATE: "contract_update",
|
||||
CONTRACT_DELETE: "contract_delete",
|
||||
|
||||
// Notes
|
||||
NOTE_CREATE: "note_create",
|
||||
NOTE_UPDATE: "note_update",
|
||||
NOTE_DELETE: "note_delete",
|
||||
|
||||
// Admin actions
|
||||
USER_CREATE: "user_create",
|
||||
USER_UPDATE: "user_update",
|
||||
USER_DELETE: "user_delete",
|
||||
USER_ROLE_CHANGE: "user_role_change",
|
||||
|
||||
// System actions
|
||||
DATA_EXPORT: "data_export",
|
||||
BULK_OPERATION: "bulk_operation",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resource types for audit logging
|
||||
*/
|
||||
export const RESOURCE_TYPES = {
|
||||
PROJECT: "project",
|
||||
TASK: "task",
|
||||
PROJECT_TASK: "project_task",
|
||||
CONTRACT: "contract",
|
||||
NOTE: "note",
|
||||
USER: "user",
|
||||
SESSION: "session",
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
* @param {Object} params - Audit log parameters
|
||||
* @param {string} params.action - Action performed (use AUDIT_ACTIONS constants)
|
||||
* @param {string} [params.userId] - ID of user performing the action
|
||||
* @param {string} [params.resourceType] - Type of resource affected (use RESOURCE_TYPES constants)
|
||||
* @param {string} [params.resourceId] - ID of the affected resource
|
||||
* @param {string} [params.ipAddress] - IP address of the user
|
||||
* @param {string} [params.userAgent] - User agent string
|
||||
* @param {Object} [params.details] - Additional details about the action
|
||||
* @param {string} [params.timestamp] - Custom timestamp (defaults to current time)
|
||||
*/
|
||||
export async function logAuditEvent({
|
||||
action,
|
||||
userId = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
details = null,
|
||||
timestamp = null,
|
||||
}) {
|
||||
try {
|
||||
// Check if we're in Edge Runtime - if so, skip database operations
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
`[Audit Log - Edge Runtime] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic import to avoid Edge Runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
const auditTimestamp = timestamp || new Date().toISOString();
|
||||
const detailsJson = details ? JSON.stringify(details) : null;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO audit_logs (
|
||||
user_id, action, resource_type, resource_id,
|
||||
ip_address, user_agent, timestamp, details
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
userId,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
auditTimestamp,
|
||||
detailsJson
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Audit log: ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to log audit event:", error);
|
||||
// Don't throw error to avoid breaking the main application flow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs with filtering and pagination
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.userId] - Filter by user ID
|
||||
* @param {string} [options.action] - Filter by action
|
||||
* @param {string} [options.resourceType] - Filter by resource type
|
||||
* @param {string} [options.resourceId] - Filter by resource ID
|
||||
* @param {string} [options.startDate] - Filter from this date (ISO string)
|
||||
* @param {string} [options.endDate] - Filter until this date (ISO string)
|
||||
* @param {number} [options.limit] - Maximum number of records to return
|
||||
* @param {number} [options.offset] - Number of records to skip
|
||||
* @param {string} [options.orderBy] - Order by field (default: timestamp)
|
||||
* @param {string} [options.orderDirection] - Order direction (ASC/DESC, default: DESC)
|
||||
* @returns {Array} Array of audit log entries
|
||||
*/
|
||||
export async function getAuditLogs({
|
||||
userId = null,
|
||||
action = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
startDate = null,
|
||||
endDate = null,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
orderBy = "timestamp",
|
||||
orderDirection = "DESC",
|
||||
} = {}) {
|
||||
try {
|
||||
// Check if we're in Edge Runtime - if so, return empty array
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
"[Audit Log - Edge Runtime] Cannot query audit logs in Edge Runtime"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Dynamic import to avoid Edge Runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
al.*,
|
||||
u.name as user_name,
|
||||
u.email as user_email
|
||||
FROM audit_logs al
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
if (userId) {
|
||||
query += " AND al.user_id = ?";
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (action) {
|
||||
query += " AND al.action = ?";
|
||||
params.push(action);
|
||||
}
|
||||
|
||||
if (resourceType) {
|
||||
query += " AND al.resource_type = ?";
|
||||
params.push(resourceType);
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
query += " AND al.resource_id = ?";
|
||||
params.push(resourceId);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
query += " AND al.timestamp >= ?";
|
||||
params.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
query += " AND al.timestamp <= ?";
|
||||
params.push(endDate);
|
||||
}
|
||||
|
||||
// Validate order direction
|
||||
const validOrderDirection = ["ASC", "DESC"].includes(
|
||||
orderDirection.toUpperCase()
|
||||
)
|
||||
? orderDirection.toUpperCase()
|
||||
: "DESC";
|
||||
|
||||
// Validate order by field
|
||||
const validOrderFields = [
|
||||
"timestamp",
|
||||
"action",
|
||||
"user_id",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
];
|
||||
const validOrderBy = validOrderFields.includes(orderBy)
|
||||
? orderBy
|
||||
: "timestamp";
|
||||
|
||||
query += ` ORDER BY al.${validOrderBy} ${validOrderDirection}`;
|
||||
|
||||
if (limit) {
|
||||
query += " LIMIT ?";
|
||||
params.push(limit);
|
||||
}
|
||||
|
||||
if (offset) {
|
||||
query += " OFFSET ?";
|
||||
params.push(offset);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
const results = stmt.all(...params);
|
||||
|
||||
// Parse details JSON for each result
|
||||
return results.map((log) => ({
|
||||
...log,
|
||||
details: log.details ? JSON.parse(log.details) : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to get audit logs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log statistics
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.startDate] - Filter from this date (ISO string)
|
||||
* @param {string} [options.endDate] - Filter until this date (ISO string)
|
||||
* @returns {Object} Statistics object
|
||||
*/
|
||||
export async function getAuditLogStats({
|
||||
startDate = null,
|
||||
endDate = null,
|
||||
} = {}) {
|
||||
try {
|
||||
// Check if we're in Edge Runtime - if so, return empty stats
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
"[Audit Log - Edge Runtime] Cannot query audit log stats in Edge Runtime"
|
||||
);
|
||||
return {
|
||||
total: 0,
|
||||
actionBreakdown: [],
|
||||
userBreakdown: [],
|
||||
resourceBreakdown: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Dynamic import to avoid Edge Runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
let baseQuery = "FROM audit_logs WHERE 1=1";
|
||||
const params = [];
|
||||
|
||||
if (startDate) {
|
||||
baseQuery += " AND timestamp >= ?";
|
||||
params.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
baseQuery += " AND timestamp <= ?";
|
||||
params.push(endDate);
|
||||
}
|
||||
|
||||
// Total count
|
||||
const totalStmt = db.prepare(`SELECT COUNT(*) as total ${baseQuery}`);
|
||||
const totalResult = totalStmt.get(...params);
|
||||
|
||||
// Actions breakdown
|
||||
const actionsStmt = db.prepare(`
|
||||
SELECT action, COUNT(*) as count
|
||||
${baseQuery}
|
||||
GROUP BY action
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
const actionsResult = actionsStmt.all(...params);
|
||||
|
||||
// Users breakdown
|
||||
const usersStmt = db.prepare(`
|
||||
SELECT
|
||||
al.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
COUNT(*) as count
|
||||
${baseQuery}
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
GROUP BY al.user_id, u.name, u.email
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
const usersResult = usersStmt.all(...params);
|
||||
|
||||
// Resource types breakdown
|
||||
const resourcesStmt = db.prepare(`
|
||||
SELECT resource_type, COUNT(*) as count
|
||||
${baseQuery}
|
||||
WHERE resource_type IS NOT NULL
|
||||
GROUP BY resource_type
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
const resourcesResult = resourcesStmt.all(...params);
|
||||
|
||||
return {
|
||||
total: totalResult.total,
|
||||
actionBreakdown: actionsResult,
|
||||
userBreakdown: usersResult,
|
||||
resourceBreakdown: resourcesResult,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to get audit log statistics:", error);
|
||||
return {
|
||||
total: 0,
|
||||
actionBreakdown: [],
|
||||
userBreakdown: [],
|
||||
resourceBreakdown: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract client information from request
|
||||
* @param {Request} req - The request object
|
||||
* @returns {Object} Object containing IP address and user agent
|
||||
*/
|
||||
export function getClientInfo(req) {
|
||||
const ipAddress =
|
||||
req.headers.get("x-forwarded-for") ||
|
||||
req.headers.get("x-real-ip") ||
|
||||
req.headers.get("cf-connecting-ip") ||
|
||||
req.ip ||
|
||||
"unknown";
|
||||
|
||||
const userAgent = req.headers.get("user-agent") || "unknown";
|
||||
|
||||
return { ipAddress, userAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware helper to log API actions
|
||||
* @param {Request} req - The request object
|
||||
* @param {string} action - The action being performed
|
||||
* @param {string} resourceType - The type of resource
|
||||
* @param {string} resourceId - The ID of the resource
|
||||
* @param {Object} session - The user session
|
||||
* @param {Object} additionalDetails - Additional details to log
|
||||
*/
|
||||
export async function logApiAction(
|
||||
req,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
session,
|
||||
additionalDetails = {}
|
||||
) {
|
||||
const { ipAddress, userAgent } = getClientInfo(req);
|
||||
|
||||
await logAuditEvent({
|
||||
action,
|
||||
userId: session?.user?.id || null,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
...additionalDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const auditLog = {
|
||||
logAuditEvent,
|
||||
getAuditLogs,
|
||||
getAuditLogStats,
|
||||
getClientInfo,
|
||||
logApiAction,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
};
|
||||
|
||||
export default auditLog;
|
||||
129
src/lib/auditLogEdge.js
Normal file
129
src/lib/auditLogEdge.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Edge-compatible audit logging utility
|
||||
* This version avoids direct database imports and can be used in Edge Runtime
|
||||
*/
|
||||
|
||||
import { AUDIT_ACTIONS, RESOURCE_TYPES } from "./auditLog.js";
|
||||
|
||||
/**
|
||||
* Log an audit event in Edge Runtime compatible way
|
||||
* @param {Object} params - Audit log parameters
|
||||
*/
|
||||
export async function logAuditEventAsync({
|
||||
action,
|
||||
userId = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
details = null,
|
||||
timestamp = null,
|
||||
}) {
|
||||
try {
|
||||
// In Edge Runtime or when database is not available, log to console
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(
|
||||
`[Audit Log - Edge] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`,
|
||||
{
|
||||
details,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to make an API call to log the event
|
||||
try {
|
||||
const response = await fetch("/api/audit-logs/log", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (fetchError) {
|
||||
// Fallback to console logging if API call fails
|
||||
console.log(
|
||||
`[Audit Log - Fallback] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`,
|
||||
{
|
||||
details,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
error: fetchError.message,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log audit event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract client information from request (Edge compatible)
|
||||
* @param {Request} req - The request object
|
||||
* @returns {Object} Object containing IP address and user agent
|
||||
*/
|
||||
export function getClientInfoEdgeCompatible(req) {
|
||||
const ipAddress =
|
||||
req.headers.get("x-forwarded-for") ||
|
||||
req.headers.get("x-real-ip") ||
|
||||
req.headers.get("cf-connecting-ip") ||
|
||||
"unknown";
|
||||
|
||||
const userAgent = req.headers.get("user-agent") || "unknown";
|
||||
|
||||
return { ipAddress, userAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware helper to log API actions (Edge compatible)
|
||||
*/
|
||||
export async function logApiActionAsync(
|
||||
req,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
session,
|
||||
additionalDetails = {}
|
||||
) {
|
||||
const { ipAddress, userAgent } = getClientInfoEdgeCompatible(req);
|
||||
|
||||
await logAuditEventAsync({
|
||||
action,
|
||||
userId: session?.user?.id || null,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
...additionalDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { AUDIT_ACTIONS, RESOURCE_TYPES };
|
||||
159
src/lib/auditLogSafe.js
Normal file
159
src/lib/auditLogSafe.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Safe audit logging that doesn't cause Edge Runtime issues
|
||||
* This module can be safely imported anywhere without causing database issues
|
||||
*/
|
||||
|
||||
// Constants that can be safely exported
|
||||
export const AUDIT_ACTIONS = {
|
||||
// Authentication
|
||||
LOGIN: "login",
|
||||
LOGOUT: "logout",
|
||||
LOGIN_FAILED: "login_failed",
|
||||
|
||||
// Projects
|
||||
PROJECT_CREATE: "project_create",
|
||||
PROJECT_UPDATE: "project_update",
|
||||
PROJECT_DELETE: "project_delete",
|
||||
PROJECT_VIEW: "project_view",
|
||||
|
||||
// Tasks
|
||||
TASK_CREATE: "task_create",
|
||||
TASK_UPDATE: "task_update",
|
||||
TASK_DELETE: "task_delete",
|
||||
TASK_STATUS_CHANGE: "task_status_change",
|
||||
|
||||
// Project Tasks
|
||||
PROJECT_TASK_CREATE: "project_task_create",
|
||||
PROJECT_TASK_UPDATE: "project_task_update",
|
||||
PROJECT_TASK_DELETE: "project_task_delete",
|
||||
PROJECT_TASK_STATUS_CHANGE: "project_task_status_change",
|
||||
|
||||
// Contracts
|
||||
CONTRACT_CREATE: "contract_create",
|
||||
CONTRACT_UPDATE: "contract_update",
|
||||
CONTRACT_DELETE: "contract_delete",
|
||||
|
||||
// Notes
|
||||
NOTE_CREATE: "note_create",
|
||||
NOTE_UPDATE: "note_update",
|
||||
NOTE_DELETE: "note_delete",
|
||||
|
||||
// Admin actions
|
||||
USER_CREATE: "user_create",
|
||||
USER_UPDATE: "user_update",
|
||||
USER_DELETE: "user_delete",
|
||||
USER_ROLE_CHANGE: "user_role_change",
|
||||
|
||||
// System actions
|
||||
DATA_EXPORT: "data_export",
|
||||
BULK_OPERATION: "bulk_operation",
|
||||
};
|
||||
|
||||
export const RESOURCE_TYPES = {
|
||||
PROJECT: "project",
|
||||
TASK: "task",
|
||||
PROJECT_TASK: "project_task",
|
||||
CONTRACT: "contract",
|
||||
NOTE: "note",
|
||||
USER: "user",
|
||||
SESSION: "session",
|
||||
SYSTEM: "system",
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe audit logging function that works in any runtime
|
||||
*/
|
||||
export async function logAuditEventSafe({
|
||||
action,
|
||||
userId = null,
|
||||
resourceType = null,
|
||||
resourceId = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
details = null,
|
||||
timestamp = null,
|
||||
}) {
|
||||
try {
|
||||
// Always log to console first
|
||||
console.log(
|
||||
`[Audit] ${action} by user ${
|
||||
userId || "anonymous"
|
||||
} on ${resourceType}:${resourceId}`
|
||||
);
|
||||
|
||||
// Check if we're in Edge Runtime
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log("[Audit] Edge Runtime detected - console logging only");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the database-enabled audit function
|
||||
try {
|
||||
const auditModule = await import("./auditLog.js");
|
||||
await auditModule.logAuditEvent({
|
||||
action,
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details,
|
||||
timestamp,
|
||||
});
|
||||
} catch (dbError) {
|
||||
console.log(
|
||||
"[Audit] Database logging failed, using console fallback:",
|
||||
dbError.message
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Audit] Failed to log audit event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract client information from request
|
||||
*/
|
||||
export function getClientInfo(req) {
|
||||
const ipAddress =
|
||||
req.headers?.get?.("x-forwarded-for") ||
|
||||
req.headers?.get?.("x-real-ip") ||
|
||||
req.headers?.get?.("cf-connecting-ip") ||
|
||||
req.ip ||
|
||||
"unknown";
|
||||
|
||||
const userAgent = req.headers?.get?.("user-agent") || "unknown";
|
||||
|
||||
return { ipAddress, userAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe API action logging
|
||||
*/
|
||||
export async function logApiActionSafe(
|
||||
req,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
session,
|
||||
additionalDetails = {}
|
||||
) {
|
||||
const { ipAddress, userAgent } = getClientInfo(req);
|
||||
|
||||
await logAuditEventSafe({
|
||||
action,
|
||||
userId: session?.user?.id || null,
|
||||
resourceType,
|
||||
resourceId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
details: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
...additionalDetails,
|
||||
},
|
||||
});
|
||||
}
|
||||
226
src/lib/auth.js
226
src/lib/auth.js
@@ -1,52 +1,60 @@
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email format"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters")
|
||||
})
|
||||
email: z.string().email("Invalid email format"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("./db.js")
|
||||
|
||||
// Validate input
|
||||
const validatedFields = loginSchema.parse(credentials)
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db.prepare(`
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("./db.js");
|
||||
|
||||
// Validate input
|
||||
const validatedFields = loginSchema.parse(credentials);
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, email, name, password_hash, role, is_active,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
WHERE email = ? AND is_active = 1
|
||||
`).get(validatedFields.email)
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials")
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
throw new Error("Account temporarily locked")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(validatedFields.password, user.password_hash)
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Increment failed attempts
|
||||
db.prepare(`
|
||||
`
|
||||
)
|
||||
.get(validatedFields.email);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
throw new Error("Account temporarily locked");
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
validatedFields.password,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Increment failed attempts
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = CASE
|
||||
@@ -55,57 +63,95 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE id = ?
|
||||
`).run(user.id)
|
||||
|
||||
throw new Error("Invalid credentials")
|
||||
}
|
||||
|
||||
// Reset failed attempts and update last login
|
||||
db.prepare(`
|
||||
`
|
||||
).run(user.id);
|
||||
|
||||
// Log failed login attempt (only in Node.js runtime)
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
|
||||
await import("./auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||
userId: user.id,
|
||||
resourceType: RESOURCE_TYPES.SESSION,
|
||||
details: {
|
||||
email: validatedFields.email,
|
||||
reason: "invalid_password",
|
||||
failed_attempts: user.failed_login_attempts + 1,
|
||||
},
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Reset failed attempts and update last login
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
last_login = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(user.id)
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = user.role
|
||||
token.userId = user.id
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token) {
|
||||
session.user.id = token.userId
|
||||
session.user.role = token.role
|
||||
}
|
||||
return session
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: '/auth/signin',
|
||||
signOut: '/auth/signout',
|
||||
error: '/auth/error'
|
||||
},
|
||||
debug: process.env.NODE_ENV === 'development'
|
||||
})
|
||||
`
|
||||
).run(user.id);
|
||||
|
||||
// Log successful login (only in Node.js runtime)
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
|
||||
await import("./auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.LOGIN,
|
||||
userId: user.id,
|
||||
resourceType: RESOURCE_TYPES.SESSION,
|
||||
details: {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = user.role;
|
||||
token.userId = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token) {
|
||||
session.user.id = token.userId;
|
||||
session.user.role = token.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
signOut: "/auth/signout",
|
||||
error: "/auth/error",
|
||||
},
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
235
src/lib/middleware/auditLog.js
Normal file
235
src/lib/middleware/auditLog.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import { logApiAction, AUDIT_ACTIONS, RESOURCE_TYPES } from "@/lib/auditLog.js";
|
||||
|
||||
/**
|
||||
* Higher-order function to add audit logging to API routes
|
||||
* @param {Function} handler - The original API route handler
|
||||
* @param {Object} auditConfig - Audit logging configuration
|
||||
* @param {string} auditConfig.action - The audit action to log
|
||||
* @param {string} auditConfig.resourceType - The resource type being accessed
|
||||
* @param {Function} [auditConfig.getResourceId] - Function to extract resource ID from request/params
|
||||
* @param {Function} [auditConfig.getAdditionalDetails] - Function to get additional details to log
|
||||
* @returns {Function} Wrapped handler with audit logging
|
||||
*/
|
||||
export function withAuditLog(handler, auditConfig) {
|
||||
return async (request, context) => {
|
||||
try {
|
||||
// Execute the original handler first
|
||||
const response = await handler(request, context);
|
||||
|
||||
// Extract resource ID if function provided
|
||||
let resourceId = null;
|
||||
if (auditConfig.getResourceId) {
|
||||
resourceId = auditConfig.getResourceId(request, context, response);
|
||||
} else if (context?.params?.id) {
|
||||
resourceId = context.params.id;
|
||||
}
|
||||
|
||||
// Get additional details if function provided
|
||||
let additionalDetails = {};
|
||||
if (auditConfig.getAdditionalDetails) {
|
||||
additionalDetails = auditConfig.getAdditionalDetails(
|
||||
request,
|
||||
context,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
// Log the action
|
||||
logApiAction(
|
||||
request,
|
||||
auditConfig.action,
|
||||
auditConfig.resourceType,
|
||||
resourceId,
|
||||
request.session,
|
||||
additionalDetails
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Log failed actions
|
||||
const resourceId = auditConfig.getResourceId
|
||||
? auditConfig.getResourceId(request, context, null)
|
||||
: context?.params?.id || null;
|
||||
|
||||
logApiAction(
|
||||
request,
|
||||
`${auditConfig.action}_failed`,
|
||||
auditConfig.resourceType,
|
||||
resourceId,
|
||||
request.session,
|
||||
{
|
||||
error: error.message,
|
||||
...(auditConfig.getAdditionalDetails
|
||||
? auditConfig.getAdditionalDetails(request, context, null)
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
|
||||
// Re-throw the error
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined audit configurations for common actions
|
||||
*/
|
||||
export const AUDIT_CONFIGS = {
|
||||
// Project actions
|
||||
PROJECT_VIEW: {
|
||||
action: AUDIT_ACTIONS.PROJECT_VIEW,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
},
|
||||
PROJECT_CREATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
getResourceId: (req, ctx, res) => res?.json?.projectId?.toString(),
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { projectData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_UPDATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_DELETE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_DELETE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
},
|
||||
|
||||
// Task actions
|
||||
TASK_VIEW: {
|
||||
action: AUDIT_ACTIONS.TASK_VIEW,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
},
|
||||
TASK_CREATE: {
|
||||
action: AUDIT_ACTIONS.TASK_CREATE,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { taskData: data };
|
||||
},
|
||||
},
|
||||
TASK_UPDATE: {
|
||||
action: AUDIT_ACTIONS.TASK_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
TASK_DELETE: {
|
||||
action: AUDIT_ACTIONS.TASK_DELETE,
|
||||
resourceType: RESOURCE_TYPES.TASK,
|
||||
},
|
||||
|
||||
// Project Task actions
|
||||
PROJECT_TASK_VIEW: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_VIEW,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
},
|
||||
PROJECT_TASK_CREATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_CREATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { taskData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_TASK_UPDATE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
PROJECT_TASK_DELETE: {
|
||||
action: AUDIT_ACTIONS.PROJECT_TASK_DELETE,
|
||||
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||
},
|
||||
|
||||
// Contract actions
|
||||
CONTRACT_VIEW: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_VIEW,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
},
|
||||
CONTRACT_CREATE: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_CREATE,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { contractData: data };
|
||||
},
|
||||
},
|
||||
CONTRACT_UPDATE: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
CONTRACT_DELETE: {
|
||||
action: AUDIT_ACTIONS.CONTRACT_DELETE,
|
||||
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||
},
|
||||
|
||||
// Note actions
|
||||
NOTE_VIEW: {
|
||||
action: AUDIT_ACTIONS.NOTE_VIEW,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
},
|
||||
NOTE_CREATE: {
|
||||
action: AUDIT_ACTIONS.NOTE_CREATE,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { noteData: data };
|
||||
},
|
||||
},
|
||||
NOTE_UPDATE: {
|
||||
action: AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
getAdditionalDetails: async (req) => {
|
||||
const data = await req.json();
|
||||
return { updatedData: data };
|
||||
},
|
||||
},
|
||||
NOTE_DELETE: {
|
||||
action: AUDIT_ACTIONS.NOTE_DELETE,
|
||||
resourceType: RESOURCE_TYPES.NOTE,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to create audit-logged API handlers
|
||||
* @param {Object} handlers - Object with HTTP method handlers
|
||||
* @param {Object} auditConfig - Audit configuration for this route
|
||||
* @returns {Object} Object with audit-logged handlers
|
||||
*/
|
||||
export function createAuditedHandlers(handlers, auditConfig) {
|
||||
const auditedHandlers = {};
|
||||
|
||||
Object.entries(handlers).forEach(([method, handler]) => {
|
||||
// Get method-specific audit config or use default
|
||||
const config = auditConfig[method] || auditConfig.default || auditConfig;
|
||||
|
||||
auditedHandlers[method] = withAuditLog(handler, config);
|
||||
});
|
||||
|
||||
return auditedHandlers;
|
||||
}
|
||||
|
||||
const auditLogMiddleware = {
|
||||
withAuditLog,
|
||||
AUDIT_CONFIGS,
|
||||
createAuditedHandlers,
|
||||
};
|
||||
|
||||
export default auditLogMiddleware;
|
||||
@@ -1,38 +1,43 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export default auth((req) => {
|
||||
const { pathname } = req.nextUrl
|
||||
|
||||
// Allow access to auth pages
|
||||
if (pathname.startsWith('/auth/')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Require authentication for all other pages
|
||||
if (!req.auth) {
|
||||
const url = new URL('/auth/signin', req.url)
|
||||
url.searchParams.set('callbackUrl', req.nextUrl.pathname)
|
||||
return Response.redirect(url)
|
||||
}
|
||||
|
||||
// Check admin routes (role check only, no database access)
|
||||
if (pathname.startsWith('/admin/')) {
|
||||
if (req.auth.user.role !== 'admin') {
|
||||
return Response.redirect(new URL('/auth/signin', req.url))
|
||||
}
|
||||
}
|
||||
})
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// Allow access to auth pages
|
||||
if (pathname.startsWith("/auth/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow access to API routes (they handle their own auth)
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Require authentication for all other pages
|
||||
if (!req.auth) {
|
||||
const url = new URL("/auth/signin", req.url);
|
||||
url.searchParams.set("callbackUrl", req.nextUrl.pathname);
|
||||
return Response.redirect(url);
|
||||
}
|
||||
|
||||
// Check admin routes (role check only, no database access)
|
||||
if (pathname.startsWith("/admin/")) {
|
||||
if (!["admin", "project_manager"].includes(req.auth.user.role)) {
|
||||
return Response.redirect(new URL("/", req.url));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (all API routes handle their own auth)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - auth pages (auth pages should be accessible)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico|auth).*)',
|
||||
],
|
||||
}
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (all API routes handle their own auth)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - auth pages (auth pages should be accessible)
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|auth).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user