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