/** * 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: `
Performance West

Reset your password

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 password

If you didn't request a password reset, you can safely ignore this email. Your password won't change.

`, text: `Reset your Performance West password: ${resetLink}\n\nThis link expires in ${RESET_TTL_MINUTES} minutes.`, }); } catch (err) { console.error("[portal-auth] forgot-password error (post-response):", err); } }); // ── POST /api/v1/auth/reset-password ───────────────────────────────────────── router.post("/reset-password", async (req: Request, res: Response) => { const { token, password } = req.body as { token?: string; password?: string }; if (!token) return res.status(400).json({ error: "Reset token required" }); if (!password || password.length < 8) { return res.status(400).json({ error: "Password must be at least 8 characters" }); } try { const result = await pool.query<{ id: number; customer_id: number; expires_at: Date; used_at: Date | null }>( `SELECT id, customer_id, expires_at, used_at FROM password_reset_tokens WHERE token = $1`, [token] ); const row = result.rows[0]; if (!row) return res.status(400).json({ error: "Invalid or expired reset link." }); if (row.used_at) return res.status(400).json({ error: "This reset link has already been used." }); if (new Date() > row.expires_at) return res.status(400).json({ error: "This reset link has expired. Please request a new one." }); const hash = await bcrypt.hash(password, 12); await pool.query(`UPDATE customers SET password_hash = $1, updated_at = NOW() WHERE id = $2`, [hash, row.customer_id]); await pool.query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id]); // Fetch customer and issue session cookie so they're logged in immediately const custResult = await pool.query<{ id: number; email: string; name: string | null }>( `SELECT id, email, name FROM customers WHERE id = $1`, [row.customer_id] ); const customer = custResult.rows[0]; if (customer) { issueCustomerCookie(res, { customerId: customer.id, email: customer.email }); } res.json({ success: true, customer: customer ? { id: customer.id, email: customer.email, name: customer.name } : null }); } catch (err) { console.error("[portal-auth] reset-password error:", err); res.status(500).json({ error: "Password reset failed. Please try again." }); } }); // ── GET /api/v1/auth/portal-status?email=... ────────────────────────────────── // // Check if an email already has an ERPNext portal account. // Used by the success page to decide: show password form (new) or login link (returning). // router.get("/portal-status", async (req: Request, res: Response) => { const email = ((req.query.email as string) || "").trim().toLowerCase(); if (!email) { return res.json({ has_account: false }); } try { const { getResource } = await import("../erpnext-client.js"); const users = (await getResource( "User", undefined, { name: email, enabled: 1 }, ["name", "full_name", "last_login"], 1, )) as Array<{ name: string; full_name: string; last_login: string | null }>; if (users.length > 0 && users[0].last_login) { // User exists AND has logged in before → returning customer return res.json({ has_account: true, returning: true, name: users[0].full_name }); } else if (users.length > 0) { // User exists but never logged in → account created but password may not be set return res.json({ has_account: true, returning: false, name: users[0].full_name }); } res.json({ has_account: false }); } catch { // ERPNext unreachable — assume no account res.json({ has_account: false }); } }); // ── POST /api/v1/auth/set-erpnext-password ──────────────────────────────────── // // Called from the success page after payment. Sets (or resets) the customer's // ERPNext portal password so they can log in at portal.performancewest.net. // // Body: { email, password, order_id?, customer_name? } // We verify ownership by checking that at least one order in PG belongs to // this email before setting the password — prevents arbitrary account takeover. // router.post("/set-erpnext-password", async (req: Request, res: Response) => { const { email, password, order_id, customer_name } = req.body as { email?: string; password?: string; order_id?: string; customer_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 { // Verify that an order exists for this email (ownership gate). Covers // every order type that spawns a Website User via checkout: CRTC, // legacy orders, formation, compliance. const ownerCheck = await pool.query( `SELECT 1 FROM canada_crtc_orders WHERE customer_email = $1 UNION ALL SELECT 1 FROM orders WHERE email = $1 UNION ALL SELECT 1 FROM formation_orders WHERE customer_email = $1 UNION ALL SELECT 1 FROM compliance_orders WHERE customer_email = $1 LIMIT 1`, [normalizedEmail], ); if (ownerCheck.rows.length === 0) { // No order found — still return success to avoid email enumeration, // but do nothing. The portal link in the email will still work later. return res.json({ success: true, created: false }); } // Find the ERPNext customer name so we can link the user let erpCustomerName: string | undefined; try { const { getResource } = await import("../erpnext-client.js"); const existing = (await getResource( "Customer", undefined, { email_id: normalizedEmail }, ["name"], 1, )) as Array<{ name: string }>; if (existing.length > 0) erpCustomerName = existing[0].name; } catch { // Non-fatal — continue without linking } // Set ERPNext Website User password (the only account system) const fullName = customer_name?.trim() || normalizedEmail.split("@")[0]; await ensureWebsiteUser(normalizedEmail, fullName); await setWebsiteUserPassword(normalizedEmail, password); if (erpCustomerName) { await linkUserToCustomer(erpCustomerName, normalizedEmail); } res.json({ success: true, created: true }); } catch (err: any) { console.error("[portal-auth] set-erpnext-password error:", err); // Extract user-friendly message from ERPNext validation errors let userMessage = "Failed to activate portal account. Please try again."; const serverMsg = err?._server_messages || err?.message || ""; if (serverMsg.includes("Password") || serverMsg.includes("password")) { // Password strength error — extract the readable part const match = serverMsg.match(/alert-warning[^>]*>([^<]+)/); userMessage = match ? match[1].trim() : "Password is too weak. Use a mix of uppercase, lowercase, numbers, and special characters."; } else if (serverMsg.includes("already exists") || serverMsg.includes("Duplicate")) { userMessage = "An account with this email already exists. Try logging in at the portal instead."; } res.status(400).json({ error: userMessage }); } }); export default router;