Initial commit: Craft2Prints with Stages 1-3
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
'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 { allowed } = await checkRateLimit(`login:customer:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!allowed) {
|
||||
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) {
|
||||
redirect(`/account/login?error=invalid&next=${encodeURIComponent(next)}`);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user