Add customer login activity logging
- Add CustomerLoginLog model to track customer login attempts - Record successful and failed customer logins - Capture email, IP address, and user agent for each attempt - Create /admin/customer-login-logs page to view activity - Show login stats (successes, failures, active customers) - Display browser/device and IP for each login attempt - Add "Customer login activity" link to admin menu - Monitor customer access patterns and detect suspicious activity - Last 30 days active customer count Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
ea6f9d2e25
commit
e69759081c
@@ -393,3 +393,14 @@ model AdminLoginLog {
|
|||||||
userAgent String?
|
userAgent String?
|
||||||
success Boolean @default(true) // false for failed login attempts
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,8 +62,14 @@ export async function loginCustomer(formData: FormData) {
|
|||||||
const next = safeNext(formData.get('next'));
|
const next = safeNext(formData.get('next'));
|
||||||
|
|
||||||
const ip = getClientIp(headers());
|
const ip = getClientIp(headers());
|
||||||
|
const userAgent = headers().get('user-agent') ?? undefined;
|
||||||
|
|
||||||
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
|
const { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
|
||||||
if (!allowed) {
|
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)}`);
|
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;
|
const valid = customer ? await verifyPassword(password, customer.passwordHash) : false;
|
||||||
|
|
||||||
if (!customer || !valid) {
|
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)}`);
|
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 linkGuestOrders(customer.id, customer.email);
|
||||||
await createSession(customer.id);
|
await createSession(customer.id);
|
||||||
redirect(next);
|
redirect(next);
|
||||||
|
|||||||
@@ -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 <div className="px-6 py-12 text-center">Unauthorized</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||||
|
<AdminNav />
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<h1 className="font-display text-3xl">Customer Login Activity</h1>
|
||||||
|
<Link href="/admin" className="text-sm text-clay hover:text-clay-dark">
|
||||||
|
Back to admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-muted">
|
||||||
|
Record of customer account access attempts. Track login patterns and identify unusual activity.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-4">
|
||||||
|
<div className="border border-line bg-surface p-4">
|
||||||
|
<p className="tag-label">Successful Logins</p>
|
||||||
|
<p className="mt-1 font-mono text-xl">{successCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-line bg-surface p-4">
|
||||||
|
<p className="tag-label">Failed Attempts</p>
|
||||||
|
<p className="mt-1 font-mono text-xl">{failureCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-line bg-surface p-4">
|
||||||
|
<p className="tag-label">Last 30 Days</p>
|
||||||
|
<p className="mt-1 font-mono text-xl">{last30Days}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-line bg-surface p-4">
|
||||||
|
<p className="tag-label">Active Customers</p>
|
||||||
|
<p className="mt-1 font-mono text-xl">{uniqueCustomers}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="mt-8 text-sm text-muted">No customer login attempts recorded.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-8 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line">
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Date & Time</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Email</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Status</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">IP Address</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Browser / Device</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={log.id} className="border-b border-line hover:bg-surface">
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">
|
||||||
|
{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',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs">
|
||||||
|
<span className="break-all">{log.email}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span
|
||||||
|
className={`tag-label rounded-full px-3 py-1 text-xs ${
|
||||||
|
log.success
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-splash-pink/10 text-splash-pink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.success ? 'Success' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">{log.ipAddress || '—'}</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-muted">
|
||||||
|
{browser} / {device}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-8 text-xs text-muted">
|
||||||
|
Showing last 200 login attempts. Use this to monitor customer access patterns and detect suspicious activity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ const ADMIN_LINKS = [
|
|||||||
{ href: '/admin/collections', label: 'All collections' },
|
{ href: '/admin/collections', label: 'All collections' },
|
||||||
{ href: '/admin/customers', label: 'Email customers' },
|
{ href: '/admin/customers', label: 'Email customers' },
|
||||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
{ 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() {
|
export default function AdminMenu() {
|
||||||
|
|||||||
Reference in New Issue
Block a user