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.
+ ) : (
+
+
+
+
+ Date & Time
+ Status
+ IP Address
+ Browser / Device
+
+
+
+ {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 (
+
+
+ {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() {