new-site/api/src/routes/portal-auth.ts
justin e87715aee7 fix(portal): onboarding/login links last 7 days, not 60 min
The rescue onboarding emails hardcoded a 60-minute expiry -- way too short for a
paid customer who hasn't engaged yet (they may not check email for hours/days),
so Paul's and Mitchell's links expired before they used them. Onboarding links
now last 7 days (ONBOARDING_TTL_MINUTES); the standard security password-RESET
window bumped 30min -> 2h. Re-issued fresh 7-day links to all 3 affected
customers (none had set a password yet) via reissue-onboarding-links.mjs, cc'd.
2026-06-09 22:50:09 -05:00

404 lines
17 KiB
TypeScript

/**
* 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: `
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 24px">
<img src="${SITE_URL}/images/logo/pw-logo.png" alt="Performance West" style="height:32px;margin-bottom:24px">
<h2 style="margin:0 0 8px;font-size:20px;color:#111">Reset your password</h2>
<p style="margin:0 0 8px;color:#555;font-size:15px">Hi ${firstName},</p>
<p style="margin:0 0 24px;color:#555;font-size:15px">
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.
</p>
<a href="${resetLink}"
style="display:inline-block;background:#2d4e78;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px">
Reset password
</a>
<p style="margin:24px 0 0;color:#999;font-size:13px">
If you didn't request a password reset, you can safely ignore this email. Your password won't change.
</p>
</div>
`,
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;