diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0068a0..9f3c8fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -393,3 +393,14 @@ model AdminLoginLog { userAgent String? success Boolean @default(true) // false for failed login attempts } + +// Audit log for customer logins — tracks customer account access +model CustomerLoginLog { + id String @id @default(cuid()) + customerId String? + email String // captured for failed attempts where customer doesn't exist + loginAt DateTime @default(now()) + ipAddress String? + userAgent String? + success Boolean @default(true) // false for failed login attempts +} diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts index a387611..c1786b1 100644 --- a/src/app/account/actions.ts +++ b/src/app/account/actions.ts @@ -62,8 +62,14 @@ export async function loginCustomer(formData: FormData) { const next = safeNext(formData.get('next')); const ip = getClientIp(headers()); + const userAgent = headers().get('user-agent') ?? undefined; + const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000); if (!allowed) { + // Log rate limited attempt + await prisma.customerLoginLog.create({ + data: { success: false, email, ipAddress: ip, userAgent }, + }); redirect(`/account/login?error=rate_limited&next=${encodeURIComponent(next)}`); } @@ -71,9 +77,18 @@ export async function loginCustomer(formData: FormData) { const valid = customer ? await verifyPassword(password, customer.passwordHash) : false; if (!customer || !valid) { + // Log failed login attempt + await prisma.customerLoginLog.create({ + data: { success: false, email, ipAddress: ip, userAgent }, + }); redirect(`/account/login?error=invalid&next=${encodeURIComponent(next)}`); } + // Log successful login + await prisma.customerLoginLog.create({ + data: { success: true, customerId: customer.id, email, ipAddress: ip, userAgent }, + }); + await linkGuestOrders(customer.id, customer.email); await createSession(customer.id); redirect(next); diff --git a/src/app/admin/customer-login-logs/page.tsx b/src/app/admin/customer-login-logs/page.tsx new file mode 100644 index 0000000..75eae92 --- /dev/null +++ b/src/app/admin/customer-login-logs/page.tsx @@ -0,0 +1,140 @@ +import { prisma } from '@/lib/prisma'; +import { isAdminAuthenticated } from '@/lib/adminAuth'; +import AdminNav from '@/components/AdminNav'; +import Link from 'next/link'; + +export default async function CustomerLoginLogsPage() { + const isAdmin = await isAdminAuthenticated(); + if (!isAdmin) { + return
Unauthorized
; + } + + const logs = await prisma.customerLoginLog.findMany({ + orderBy: { loginAt: 'desc' }, + take: 200, + }); + + 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; + + // Count unique customers who logged in last 30 days + const last30DaysLogs = logs.filter( + (l) => l.loginAt > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) && l.success && l.customerId + ); + const uniqueCustomers = new Set(last30DaysLogs.map((l) => l.customerId)).size; + + return ( +
+ +
+

Customer Login Activity

+ + Back to admin + +
+ +

+ Record of customer account access attempts. Track login patterns and identify unusual activity. +

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

Successful Logins

+

{successCount}

+
+
+

Failed Attempts

+

{failureCount}

+
+
+

Last 30 Days

+

{last30Days}

+
+
+

Active Customers

+

{uniqueCustomers}

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

No customer 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 & TimeEmailStatusIP 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.email} + + + {log.success ? 'Success' : 'Failed'} + + {log.ipAddress || '—'} + {browser} / {device} +
+
+ )} + +

+ Showing last 200 login attempts. Use this to monitor customer access patterns and detect suspicious activity. +

+
+ ); +} diff --git a/src/components/AdminMenu.tsx b/src/components/AdminMenu.tsx index e9eeb01..981f49a 100644 --- a/src/components/AdminMenu.tsx +++ b/src/components/AdminMenu.tsx @@ -22,7 +22,8 @@ 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' }, + { href: '/admin/login-logs', label: 'Admin login activity' }, + { href: '/admin/customer-login-logs', label: 'Customer login activity' }, ]; export default function AdminMenu() {