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.
404 lines
17 KiB
TypeScript
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;
|