diff --git a/app/admin/audit/page.tsx b/app/admin/audit/page.tsx new file mode 100644 index 0000000..0e8885b --- /dev/null +++ b/app/admin/audit/page.tsx @@ -0,0 +1,115 @@ +import { connectDB } from "@/lib/db"; +import AuditLog from "@/models/AuditLog"; +import { headers } from "next/headers"; + +// Force Next.js to dynamically render this page on every request +export const dynamic = "force-dynamic"; + +export default async function AuditLogPage() { + // 1. Superadmin Authorization Check + // In a real application, you would use next-auth's `getServerSession` or similar here. + // For now, we mock the auth check by looking for a specific request header. + const headersList = await headers(); + const userRole = headersList.get("x-user-role"); + + // If the user isn't a superadmin, deny access + if (userRole !== "superadmin") { + return ( +
+
+

403 - Forbidden

+

+ You must be a superadmin to view the audit logs. +

+
+
+ ); + } + + // 2. Fetch Data + await connectDB(); + + // Fetch the latest 100 audit logs + const logs = await AuditLog.find({}) + .sort({ createdAt: -1 }) + .limit(100) + .lean(); + + // 3. Render Table UI + return ( +
+
+

System Audit Logs

+ + Superadmin Only + +
+ +
+ + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + ))} + + {logs.length === 0 && ( + + + + )} + +
TimestampActionActor IDTargetDiff / ChangesIP & Device
+ {new Date(log.createdAt).toLocaleString()} + + {log.action} + + {log.actorId?.toString() || 'System'} + +
+ + {log.targetType} + + + {log.targetId?.toString()} + +
+
+ {log.diff && Object.keys(log.diff).length > 0 ? ( +
+                      {JSON.stringify(log.diff, null, 2)}
+                    
+ ) : ( + No changes recorded + )} +
+
{log.ip}
+
+ {log.userAgent} +
+
+ No audit logs found in the database. +
+
+
+ ); +} diff --git a/app/api/admin/example/route.ts b/app/api/admin/example/route.ts new file mode 100644 index 0000000..81c827b --- /dev/null +++ b/app/api/admin/example/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { logAudit } from "@/utils/auditLogger"; +import { connectDB } from "@/lib/db"; + +export async function POST(req: NextRequest) { + await connectDB(); + + // updating a user role + const targetUserId = "60c72b2f9b1d8e1a4c8e4d3a"; // Mock target ID + + // Call the audit logger at the end + await logAudit({ + req, + action: "UPDATE_USER_ROLE", + targetType: "User", + targetId: targetUserId, + diff: { + oldRole: "member", + newRole: "moderator" + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/models/AuditLog.ts b/models/AuditLog.ts new file mode 100644 index 0000000..03e45fd --- /dev/null +++ b/models/AuditLog.ts @@ -0,0 +1,54 @@ +import mongoose, { Schema, Document, Model } from 'mongoose'; + +export interface IAuditLog extends Document { + actorId: mongoose.Types.ObjectId; + action: string; + targetType: string; + targetId: mongoose.Types.ObjectId; + diff?: Record; + ip?: string; + userAgent?: string; + createdAt: Date; +} + +const AuditLogSchema: Schema = new Schema( + { + actorId: { + type: Schema.Types.ObjectId, + ref: 'User', // Basically tells the ID belongs to a document from the User collection(for future implemetation) + required: true, + }, + action: { + type: String, + required: true, + }, + targetType: { + type: String, + required: true, + }, + targetId: { + type: Schema.Types.ObjectId, + required: true, + }, + diff: { + type: Schema.Types.Mixed, + }, + ip: { + type: String, + }, + userAgent: { + type: String, + }, + createdAt: { + type: Date, + default: Date.now, + index: true, + expires: '2y', + }, + } +); + +const AuditLog: Model = + mongoose.models.AuditLog || mongoose.model('AuditLog', AuditLogSchema); + +export default AuditLog; diff --git a/utils/auditLogger.ts b/utils/auditLogger.ts new file mode 100644 index 0000000..4df46c1 --- /dev/null +++ b/utils/auditLogger.ts @@ -0,0 +1,50 @@ +import { NextRequest } from 'next/server'; +import { Types } from 'mongoose'; +import AuditLog from '@/models/AuditLog'; + +interface LogAuditParams { + req: NextRequest | Request; + action: string; + targetType: string; + targetId: Types.ObjectId | string; + diff?: Record; +} + +/** + * Helper function to create an Audit Log entry. + */ +export const logAudit = async ({ + req, + action, + targetType, + targetId, + diff, +}: LogAuditParams): Promise => { + try { + const rawIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || ''; + const ip = rawIp.split(',')[0].trim(); + + const userAgent = req.headers.get('user-agent') || 'Unknown Device'; + + const rawActorId = req.headers.get('x-user-id'); + const actorId = rawActorId ? new Types.ObjectId(rawActorId) : new Types.ObjectId(); + + if (!rawActorId) { + console.warn(`[AuditLog] Missing actorId for action: ${action}. Is the user authenticated?`); + } + + // Save to database + await AuditLog.create({ + actorId, + action, + targetType, + targetId: new Types.ObjectId(targetId), + diff, + ip, + userAgent, + }); + + } catch (error) { + console.error('[AuditLog] Failed to create audit log:', error); + } +}; \ No newline at end of file