/** * Customer portal authentication — email + password. * * POST /api/v1/auth/register { email, password, name? } * POST /api/v1/auth/login { email, password } * POST /api/v1/auth/logout * GET /api/v1/auth/me * POST /api/v1/auth/forgot-password { email } * POST /api/v1/auth/reset-password { token, password } */ import { Router, Request, Response } from "express"; import bcrypt from "bcryptjs"; import crypto from "crypto"; import nodemailer from "nodemailer"; import { pool } from "../db.js"; const SITE_URL = process.env.SITE_URL || "https://performancewest.net"; // Password-RESET window for an existing account (security-sensitive): 2 hours. const RESET_TTL_MINUTES = 120; // Onboarding / first-password window for a NEW customer who hasn't engaged yet // (e.g. set-password invites): 7 days, so the link doesn't expire before they // get to it. These customers paid and just need to get in; a short window // strands them. export const ONBOARDING_TTL_MINUTES = 7 * 24 * 60; async function sendEmail(opts: { to: string; subject: string; html: string; text: string }) { const t = nodemailer.createTransport({ host: process.env.SMTP_HOST || "", port: parseInt(process.env.SMTP_PORT || "587"), secure: false, auth: { user: process.env.SMTP_USER || "", pass: process.env.SMTP_PASS || "" }, }); await t.sendMail({ from: process.env.SMTP_FROM || "noreply@performancewest.net", ...opts }); } import { issueCustomerCookie, clearCustomerCookie, optionalCustomerAuth, } from "../middleware/customer-auth.js"; import { ensureWebsiteUser, setWebsiteUserPassword, linkUserToCustomer, } from "../erpnext-client.js"; const router = Router(); // ── POST /api/v1/auth/register ──────────────────────────────────────────────── router.post("/register", async (req: Request, res: Response) => { const { email, password, name } = req.body as { email?: string; password?: string; name?: string }; if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: "Valid email required" }); } if (!password || password.length < 8) { return res.status(400).json({ error: "Password must be at least 8 characters" }); } const normalizedEmail = email.trim().toLowerCase(); try { // Check if already registered const existing = await pool.query( `SELECT id FROM customers WHERE email = $1 AND password_hash IS NOT NULL`, [normalizedEmail] ); if (existing.rows.length > 0) { return res.status(409).json({ error: "An account with this email already exists. Please log in." }); } const hash = await bcrypt.hash(password, 12); const result = await pool.query<{ id: number; name: string | null }>( `INSERT INTO customers (email, name, password_hash) VALUES ($1, $2, $3) ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = COALESCE(EXCLUDED.name, customers.name), updated_at = NOW() RETURNING id, name`, [normalizedEmail, name?.trim() || null, hash] ); const customer = result.rows[0]; // Backfill existing orders await pool.query( `UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`, [customer.id, normalizedEmail] ); await pool.query( `UPDATE orders SET customer_id = $1 WHERE email = $2 AND customer_id IS NULL`, [customer.id, normalizedEmail] ); issueCustomerCookie(res, { customerId: customer.id, email: normalizedEmail }); res.json({ success: true, customer: { id: customer.id, email: normalizedEmail, name: customer.name } }); } catch (err) { console.error("[portal-auth] register error:", err); res.status(500).json({ error: "Registration failed. Please try again." }); } }); // ── POST /api/v1/auth/login ─────────────────────────────────────────────────── router.post("/login", async (req: Request, res: Response) => { const { email, password } = req.body as { email?: string; password?: string }; if (!email || !password) { return res.status(400).json({ error: "Email and password required" }); } const normalizedEmail = email.trim().toLowerCase(); try { const result = await pool.query<{ id: number; name: string | null; password_hash: string | null }>( `SELECT id, name, password_hash FROM customers WHERE email = $1`, [normalizedEmail] ); const customer = result.rows[0]; if (!customer || !customer.password_hash) { // Account exists from a prior order but no password set yet if (customer && !customer.password_hash) { return res.status(401).json({ error: "No password set for this account. Please register to set one.", code: "NO_PASSWORD" }); } return res.status(401).json({ error: "Invalid email or password" }); } const valid = await bcrypt.compare(password, customer.password_hash); if (!valid) { return res.status(401).json({ error: "Invalid email or password" }); } // Backfill existing orders await pool.query( `UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`, [customer.id, normalizedEmail] ); issueCustomerCookie(res, { customerId: customer.id, email: normalizedEmail }); res.json({ success: true, customer: { id: customer.id, email: normalizedEmail, name: customer.name } }); } catch (err) { console.error("[portal-auth] login error:", err); res.status(500).json({ error: "Login failed. Please try again." }); } }); // ── POST /api/v1/auth/logout ────────────────────────────────────────────────── router.post("/logout", (_req: Request, res: Response) => { clearCustomerCookie(res); res.json({ success: true }); }); // ── GET /api/v1/auth/me ─────────────────────────────────────────────────────── router.get("/me", optionalCustomerAuth, async (req: Request, res: Response) => { if (!req.customer) { return res.json({ authenticated: false }); } try { const result = await pool.query<{ id: number; email: string; name: string | null; company: string | null; phone: string | null }>( `SELECT id, email, name, company, phone FROM customers WHERE id = $1`, [req.customer.customerId] ); const customer = result.rows[0]; if (!customer) { clearCustomerCookie(res); return res.json({ authenticated: false }); } res.json({ authenticated: true, customer }); } catch (err) { console.error("[portal-auth] me error:", err); res.status(500).json({ error: "Internal error" }); } }); // ── POST /api/v1/auth/forgot-password ──────────────────────────────────────── router.post("/forgot-password", async (req: Request, res: Response) => { const { email } = req.body as { email?: string }; if (!email) return res.status(400).json({ error: "Email required" }); const normalizedEmail = email.trim().toLowerCase(); // Always return success to prevent email enumeration res.json({ success: true, message: "If an account exists, a reset link has been sent." }); try { const result = await pool.query<{ id: number; name: string | null }>( `SELECT id, name FROM customers WHERE email = $1`, [normalizedEmail] ); const customer = result.rows[0]; if (!customer) return; // silent — response already sent const token = crypto.randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000); await pool.query( `INSERT INTO password_reset_tokens (customer_id, token, expires_at) VALUES ($1, $2, $3)`, [customer.id, token, expiresAt] ); const resetLink = `${SITE_URL}/account/reset-password?token=${token}`; const firstName = customer.name?.split(" ")[0] || "there"; await sendEmail({ to: normalizedEmail, subject: "Reset your Performance West password", html: `
Hi ${firstName},
We received a request to reset the password for your Performance West account. Click the button below to choose a new password. This link expires in ${RESET_TTL_MINUTES} minutes.
Reset passwordIf you didn't request a password reset, you can safely ignore this email. Your password won't change.