diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..c8f09cd --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,129 @@ +/** + * Magic-link customer authentication. + * + * Flow: + * 1. Customer submits email → POST /api/auth/magic-link + * 2. Server stores a token (15-min TTL) on the customer record + * 3. Email with link is sent: /account/verify?token= + * 4. GET /api/auth/verify?token= validates token, sets session + * + * Session is stored in express-session (PostgreSQL-backed via connect-pg-simple). + */ + +import { db } from "./db"; +import { customers } from "../shared/schema"; +import { eq } from "drizzle-orm"; +import crypto from "crypto"; +import { sendMagicLink } from "./email"; +import type { Request, Response } from "express"; + +function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +export async function requestMagicLink(req: Request, res: Response) { + const { email } = req.body as { email?: string }; + if (!email || !email.includes("@")) { + return res.status(400).json({ error: "Valid email required" }); + } + + const normalEmail = email.toLowerCase().trim(); + const token = generateToken(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + let [customer] = await db + .select({ id: customers.id }) + .from(customers) + .where(eq(customers.email, normalEmail)); + + if (!customer) { + const [created] = await db + .insert(customers) + .values({ email: normalEmail, magicLinkToken: token, magicLinkTokenExpiresAt: expiresAt }) + .returning({ id: customers.id }); + customer = created; + } else { + await db + .update(customers) + .set({ magicLinkToken: token, magicLinkTokenExpiresAt: expiresAt }) + .where(eq(customers.id, customer.id)); + } + + const baseUrl = `${req.protocol}://${req.get("host")}`; + await sendMagicLink(normalEmail, token, baseUrl); + + res.json({ ok: true }); +} + +export async function verifyMagicLink(req: Request, res: Response) { + const { token } = req.query as { token?: string }; + if (!token) return res.status(400).json({ error: "Token required" }); + + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.magicLinkToken, token)); + + if (!customer || !customer.magicLinkTokenExpiresAt) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (new Date() > new Date(customer.magicLinkTokenExpiresAt)) { + return res.status(401).json({ error: "Token expired" }); + } + + await db + .update(customers) + .set({ magicLinkToken: null, magicLinkTokenExpiresAt: null }) + .where(eq(customers.id, customer.id)); + + (req.session as any).customerId = customer.id; + + res.json({ + ok: true, + customer: { + id: customer.id, + email: customer.email, + firstName: customer.firstName, + lastName: customer.lastName, + }, + }); +} + +export function requireCustomer(req: Request, res: Response, next: Function) { + if (!(req.session as any).customerId) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +} + +export function requireAdmin(req: Request, res: Response, next: Function) { + if (!(req.session as any).adminUserId) { + return res.status(401).json({ error: "Admin access required" }); + } + next(); +} + +export async function logout(req: Request, res: Response) { + req.session.destroy(() => { + res.json({ ok: true }); + }); +} + +export async function me(req: Request, res: Response) { + const customerId = (req.session as any).customerId; + if (!customerId) return res.json({ customer: null }); + + const [customer] = await db + .select({ + id: customers.id, + email: customers.email, + firstName: customers.firstName, + lastName: customers.lastName, + phone: customers.phone, + }) + .from(customers) + .where(eq(customers.id, customerId)); + + res.json({ customer: customer || null }); +}