- 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>
152 lines
5.7 KiB
TypeScript
152 lines
5.7 KiB
TypeScript
'use server';
|
|
|
|
import { randomBytes } from 'crypto';
|
|
import { headers } from 'next/headers';
|
|
import { redirect } from 'next/navigation';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { createSession, clearSession, hashPassword, verifyPassword } from '@/lib/auth';
|
|
import { sendPasswordResetEmail } from '@/lib/mail';
|
|
import { upsertMailingListEntry } from '@/lib/mailingList';
|
|
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
|
import { verifyTurnstileToken } from '@/lib/turnstile';
|
|
|
|
const RESET_TOKEN_DURATION_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
// Pulls in any past guest-checkout orders placed under this email so they show
|
|
// up in the customer's order history once they have an account.
|
|
async function linkGuestOrders(customerId: string, email: string) {
|
|
await prisma.order.updateMany({
|
|
where: { email, customerId: null },
|
|
data: { customerId },
|
|
});
|
|
}
|
|
|
|
function safeNext(next: FormDataEntryValue | null) {
|
|
const path = String(next ?? '');
|
|
return path.startsWith('/') && !path.startsWith('//') ? path : '/account';
|
|
}
|
|
|
|
export async function registerCustomer(formData: FormData) {
|
|
const name = String(formData.get('name') ?? '').trim();
|
|
const email = String(formData.get('email') ?? '').trim().toLowerCase();
|
|
const password = String(formData.get('password') ?? '');
|
|
const confirmPassword = String(formData.get('confirmPassword') ?? '');
|
|
const next = safeNext(formData.get('next'));
|
|
|
|
if (!name || !email || !password) redirect('/account/register?error=missing');
|
|
if (password !== confirmPassword) redirect('/account/register?error=mismatch');
|
|
if (password.length < 8) redirect('/account/register?error=weak');
|
|
|
|
const ip = getClientIp(headers());
|
|
const turnstileToken = String(formData.get('cf-turnstile-response') ?? '');
|
|
const { success } = await verifyTurnstileToken(turnstileToken, ip);
|
|
if (!success) redirect('/account/register?error=captcha');
|
|
|
|
const existing = await prisma.customer.findUnique({ where: { email } });
|
|
if (existing) redirect('/account/register?error=taken');
|
|
|
|
const passwordHash = await hashPassword(password);
|
|
const customer = await prisma.customer.create({
|
|
data: { email, passwordHash, name },
|
|
});
|
|
|
|
await upsertMailingListEntry({ email: customer.email, name: customer.name, source: 'CUSTOMER' });
|
|
await linkGuestOrders(customer.id, customer.email);
|
|
await createSession(customer.id);
|
|
redirect(next);
|
|
}
|
|
|
|
export async function loginCustomer(formData: FormData) {
|
|
const email = String(formData.get('email') ?? '').trim().toLowerCase();
|
|
const password = String(formData.get('password') ?? '');
|
|
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)}`);
|
|
}
|
|
|
|
const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null;
|
|
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);
|
|
}
|
|
|
|
export async function logoutCustomer() {
|
|
clearSession();
|
|
redirect('/');
|
|
}
|
|
|
|
export async function requestPasswordReset(formData: FormData) {
|
|
const email = String(formData.get('email') ?? '').trim().toLowerCase();
|
|
const customer = email ? await prisma.customer.findUnique({ where: { email } }) : null;
|
|
|
|
if (customer) {
|
|
const token = randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + RESET_TOKEN_DURATION_MS);
|
|
|
|
await prisma.passwordResetToken.deleteMany({ where: { customerId: customer.id } });
|
|
await prisma.passwordResetToken.create({
|
|
data: { customerId: customer.id, token, expiresAt },
|
|
});
|
|
|
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
|
await sendPasswordResetEmail({
|
|
to: customer.email,
|
|
name: customer.name,
|
|
link: `${siteUrl}/account/reset-password?token=${token}`,
|
|
});
|
|
}
|
|
|
|
// Same outcome whether or not the email matched an account, so we don't
|
|
// reveal which emails are registered.
|
|
redirect('/account/forgot-password?sent=1');
|
|
}
|
|
|
|
export async function resetPassword(formData: FormData) {
|
|
const token = String(formData.get('token') ?? '');
|
|
const password = String(formData.get('password') ?? '');
|
|
const confirmPassword = String(formData.get('confirmPassword') ?? '');
|
|
|
|
if (!token) redirect('/account/reset-password?error=invalid');
|
|
if (password !== confirmPassword) redirect(`/account/reset-password?token=${token}&error=mismatch`);
|
|
if (password.length < 8) redirect(`/account/reset-password?token=${token}&error=weak`);
|
|
|
|
const resetToken = await prisma.passwordResetToken.findUnique({ where: { token } });
|
|
if (!resetToken || resetToken.expiresAt < new Date()) {
|
|
redirect('/account/reset-password?error=expired');
|
|
}
|
|
|
|
const passwordHash = await hashPassword(password);
|
|
await prisma.customer.update({
|
|
where: { id: resetToken.customerId },
|
|
data: { passwordHash },
|
|
});
|
|
await prisma.passwordResetToken.delete({ where: { token } });
|
|
|
|
await createSession(resetToken.customerId);
|
|
redirect('/account');
|
|
}
|