Files
craft2prints/src/app/account/actions.ts
T
AndymickandClaude Haiku 4.5 e69759081c 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>
2026-07-16 21:43:09 +01:00

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');
}