diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6bee6b7..c0068a0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -383,3 +383,13 @@ model OrderItem { placementPreviewUrl String? // front design shown composited on the actual product photo, for reference — where does this go when printing placementPreviewUrlBack String? // same, for the back } + +// Audit log for admin logins — tracks when the admin panel is accessed +model AdminLoginLog { + id String @id @default(cuid()) + loginAt DateTime @default(now()) + logoutAt DateTime? // null = still logged in (or session expired without explicit logout) + ipAddress String? + userAgent String? + success Boolean @default(true) // false for failed login attempts +} diff --git a/src/app/admin/login-logs/page.tsx b/src/app/admin/login-logs/page.tsx new file mode 100644 index 0000000..1a8f12c --- /dev/null +++ b/src/app/admin/login-logs/page.tsx @@ -0,0 +1,126 @@ +import { prisma } from '@/lib/prisma'; +import { isAdminAuthenticated } from '@/lib/adminAuth'; +import AdminNav from '@/components/AdminNav'; +import Link from 'next/link'; + +export default async function LoginLogsPage() { + const isAdmin = await isAdminAuthenticated(); + if (!isAdmin) { + return
Unauthorized
; + } + + const logs = await prisma.adminLoginLog.findMany({ + orderBy: { loginAt: 'desc' }, + take: 100, + }); + + const successCount = logs.filter((l) => l.success).length; + const failureCount = logs.filter((l) => !l.success).length; + const last30Days = logs.filter( + (l) => l.loginAt > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + ).length; + + return ( +
+ +
+

Login Activity

+ + Back to admin + +
+ +

+ Record of admin panel access attempts. IP addresses and user agents are logged for security. +

+ + {/* Stats */} +
+
+

Successful Logins

+

{successCount}

+
+
+

Failed Attempts

+

{failureCount}

+
+
+

Last 30 Days

+

{last30Days}

+
+
+ + {/* Logs */} + {logs.length === 0 ? ( +

No login attempts recorded.

+ ) : ( +
+ + + + + + + + + + + {logs.map((log) => { + // Parse user agent to show simplified browser/device info + const ua = log.userAgent || 'Unknown'; + let device = 'Unknown'; + if (ua.includes('Windows')) device = 'Windows'; + else if (ua.includes('Mac')) device = 'Mac'; + else if (ua.includes('Linux')) device = 'Linux'; + else if (ua.includes('iPhone')) device = 'iPhone'; + else if (ua.includes('Android')) device = 'Android'; + + let browser = 'Unknown'; + if (ua.includes('Chrome')) browser = 'Chrome'; + else if (ua.includes('Safari')) browser = 'Safari'; + else if (ua.includes('Firefox')) browser = 'Firefox'; + else if (ua.includes('Edge')) browser = 'Edge'; + + return ( + + + + + + + ); + })} + +
Date & TimeStatusIP AddressBrowser / Device
+ {log.loginAt.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + })}{' '} + {log.loginAt.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + + {log.success ? 'Success' : 'Failed'} + + {log.ipAddress || '—'} + {browser} / {device} +
+
+ )} + +

+ Showing last 100 login attempts. Data is kept for security auditing purposes. +

+
+ ); +} diff --git a/src/app/admin/login/actions.ts b/src/app/admin/login/actions.ts index 1f440e2..10c5429 100644 --- a/src/app/admin/login/actions.ts +++ b/src/app/admin/login/actions.ts @@ -2,6 +2,7 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; import { createAdminSession, clearAdminSession } from '@/lib/adminAuth'; import { checkRateLimit, getClientIp } from '@/lib/rateLimit'; @@ -16,15 +17,30 @@ export async function loginAdmin(formData: FormData) { const next = safeNext(formData.get('next')); const ip = getClientIp(headers()); + const userAgent = headers().get('user-agent') ?? undefined; + const { allowed } = await checkRateLimit(`login:admin:${ip}`, 5, 15 * 60 * 1000); if (!allowed) { + // Log failed attempt (rate limited) + await prisma.adminLoginLog.create({ + data: { success: false, ipAddress: ip, userAgent }, + }); redirect(`/admin/login?error=rate_limited&next=${encodeURIComponent(next)}`); } if (username !== process.env.ADMIN_USER || password !== process.env.ADMIN_PASSWORD) { + // Log failed attempt (wrong credentials) + await prisma.adminLoginLog.create({ + data: { success: false, ipAddress: ip, userAgent }, + }); redirect(`/admin/login?error=invalid&next=${encodeURIComponent(next)}`); } + // Log successful login + await prisma.adminLoginLog.create({ + data: { success: true, ipAddress: ip, userAgent }, + }); + await createAdminSession(); redirect(next); } diff --git a/src/components/AdminMenu.tsx b/src/components/AdminMenu.tsx index 7b6b726..e9eeb01 100644 --- a/src/components/AdminMenu.tsx +++ b/src/components/AdminMenu.tsx @@ -22,6 +22,7 @@ const ADMIN_LINKS = [ { href: '/admin/collections', label: 'All collections' }, { href: '/admin/customers', label: 'Email customers' }, { href: '/admin/maintenance', label: 'Maintenance' }, + { href: '/admin/login-logs', label: 'Login activity' }, ]; export default function AdminMenu() {