Customer portal login previously checked a bcrypt customers.password_hash in Postgres, while portal.performancewest.net validated against ERPNext — two stores that drifted (the Paul Wilson lockout). Consolidate on ERPNext: - erpnext-client: add verifyWebsiteUserPassword() — delegates the credential check to Frappe /api/method/login (Host header = site name; 200=ok,401=bad). - portal-auth /login: verify against ERPNext, then mint the pw_customer cookie. - portal-auth /register: create+set the ERPNext password (authority) and upsert a password-less customers profile row; takeover guard still honors any legacy PG password until the column is dropped. - portal-auth /reset-password + /forgot-password: write the new password to ERPNext; forgot-password now also works for ERPNext-only users (creates the PG profile row on demand). - Legacy customers with only a PG bcrypt password reset via forgot-password. - checkout: refresh the stale comment (customers row is now a profile, no pw). Build + typecheck green.
483 lines
21 KiB
TypeScript
483 lines
21 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 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,
|
|
verifyWebsiteUserPassword,
|
|
linkUserToCustomer,
|
|
} from "../erpnext-client.js";
|
|
|
|
const router = Router();
|
|
|
|
// ── ERPNext is the single source of truth for customer passwords ─────────────
|
|
//
|
|
// Historically this file kept a bcrypt `customers.password_hash` AND ERPNext
|
|
// kept its own Website User password — two stores that drifted, so a customer
|
|
// could have a password in one and not the other (and the portal at
|
|
// portal.performancewest.net validates against ERPNext while this API used the
|
|
// PG hash). We now delegate ALL password operations to ERPNext:
|
|
//
|
|
// register / reset -> setWebsiteUserPassword (writes ERPNext)
|
|
// login -> verifyWebsiteUserPassword (checks ERPNext)
|
|
//
|
|
// Legacy customers who only ever had a Postgres bcrypt password can no longer
|
|
// log in directly — they use /forgot-password to set a password in ERPNext.
|
|
// The leftover `customers.password_hash` is no longer read for auth; it only
|
|
// still serves as a "this email already has an account" takeover guard in
|
|
// /register until the column is dropped.
|
|
//
|
|
// The `customers` table remains the PG-side profile + order-linking record
|
|
// (customer_id FK).
|
|
|
|
/** Ensure a password-less `customers` profile row exists; return its id. */
|
|
async function ensureCustomerProfile(
|
|
email: string,
|
|
name?: string | null,
|
|
): Promise<number> {
|
|
const result = await pool.query<{ id: number }>(
|
|
`INSERT INTO customers (email, name)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (email) DO UPDATE SET
|
|
name = COALESCE(EXCLUDED.name, customers.name),
|
|
updated_at = NOW()
|
|
RETURNING id`,
|
|
[email, name?.trim() || null],
|
|
);
|
|
return result.rows[0].id;
|
|
}
|
|
|
|
/** Find the ERPNext Customer doc name for this email, if any (best-effort). */
|
|
async function findErpCustomerName(email: string): Promise<string | undefined> {
|
|
try {
|
|
const { getResource } = await import("../erpnext-client.js");
|
|
const existing = (await getResource(
|
|
"Customer",
|
|
undefined,
|
|
{ email_id: email },
|
|
["name"],
|
|
1,
|
|
)) as Array<{ name: string }>;
|
|
return existing[0]?.name;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// ── 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 {
|
|
// Already-registered check. Treat the account as existing if EITHER:
|
|
// (a) an ERPNext Website User has actually logged in before, OR
|
|
// (b) a legacy bcrypt password still exists in Postgres (pre-migration).
|
|
// A Website User with no last_login and no PG hash is just a checkout stub,
|
|
// so we still allow setting the first password there.
|
|
const { getResource } = await import("../erpnext-client.js");
|
|
const [users, legacy] = await Promise.all([
|
|
getResource(
|
|
"User",
|
|
undefined,
|
|
{ name: normalizedEmail },
|
|
["name", "last_login"],
|
|
1,
|
|
).catch(() => []) as Promise<Array<{ name: string; last_login: string | null }>>,
|
|
pool.query<{ password_hash: string | null }>(
|
|
`SELECT password_hash FROM customers WHERE email = $1`,
|
|
[normalizedEmail],
|
|
),
|
|
]);
|
|
const hasErpLogin = users.length > 0 && !!users[0].last_login;
|
|
const hasLegacyPassword = !!legacy.rows[0]?.password_hash;
|
|
if (hasErpLogin || hasLegacyPassword) {
|
|
return res.status(409).json({ error: "An account with this email already exists. Please log in." });
|
|
}
|
|
|
|
// ERPNext is the password authority: create the Website User if needed,
|
|
// then set the password there.
|
|
const fullName = name?.trim() || normalizedEmail.split("@")[0];
|
|
await ensureWebsiteUser(normalizedEmail, fullName);
|
|
await setWebsiteUserPassword(normalizedEmail, password);
|
|
const erpCustomerName = await findErpCustomerName(normalizedEmail);
|
|
if (erpCustomerName) await linkUserToCustomer(erpCustomerName, normalizedEmail);
|
|
|
|
// Maintain the PG profile row (no password stored here anymore).
|
|
const customerId = await ensureCustomerProfile(normalizedEmail, name);
|
|
|
|
// Backfill existing orders
|
|
await pool.query(
|
|
`UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`,
|
|
[customerId, normalizedEmail]
|
|
);
|
|
await pool.query(
|
|
`UPDATE orders SET customer_id = $1 WHERE email = $2 AND customer_id IS NULL`,
|
|
[customerId, normalizedEmail]
|
|
);
|
|
|
|
issueCustomerCookie(res, { customerId, email: normalizedEmail });
|
|
res.json({ success: true, customer: { id: customerId, email: normalizedEmail, name: fullName } });
|
|
} catch (err: any) {
|
|
console.error("[portal-auth] register error:", err);
|
|
// Surface ERPNext password-strength errors to the user.
|
|
const serverMsg = err?.body?._server_messages || err?.message || "";
|
|
if (/password/i.test(serverMsg)) {
|
|
return res.status(400).json({ error: "Password is too weak. Use a mix of uppercase, lowercase, numbers, and symbols." });
|
|
}
|
|
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 {
|
|
// Delegate the credential check to ERPNext (single source of truth).
|
|
// Legacy customers who only had a Postgres bcrypt password no longer have a
|
|
// usable credential here — they reset via /forgot-password, which writes the
|
|
// new password to ERPNext.
|
|
const valid = await verifyWebsiteUserPassword(normalizedEmail, password);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: "Invalid email or password" });
|
|
}
|
|
|
|
// Ensure a PG profile row exists so routes that join on customer_id work.
|
|
const customerId = await ensureCustomerProfile(normalizedEmail);
|
|
const profile = await pool.query<{ name: string | null }>(
|
|
`SELECT name FROM customers WHERE id = $1`,
|
|
[customerId]
|
|
);
|
|
|
|
// Backfill existing orders
|
|
await pool.query(
|
|
`UPDATE canada_crtc_orders SET customer_id = $1 WHERE customer_email = $2 AND customer_id IS NULL`,
|
|
[customerId, normalizedEmail]
|
|
);
|
|
|
|
issueCustomerCookie(res, { customerId, email: normalizedEmail });
|
|
res.json({ success: true, customer: { id: customerId, email: normalizedEmail, name: profile.rows[0]?.name ?? null } });
|
|
} 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 {
|
|
let customer = (await pool.query<{ id: number; name: string | null }>(
|
|
`SELECT id, name FROM customers WHERE email = $1`,
|
|
[normalizedEmail]
|
|
)).rows[0];
|
|
|
|
// No PG profile row yet — but the customer may exist in ERPNext (the
|
|
// password authority). If a Website User exists there, create the profile
|
|
// row so we can mint a reset token. Otherwise stay silent (no account).
|
|
if (!customer) {
|
|
const { getResource } = await import("../erpnext-client.js");
|
|
const users = (await getResource(
|
|
"User",
|
|
undefined,
|
|
{ name: normalizedEmail },
|
|
["name", "full_name"],
|
|
1,
|
|
).catch(() => [])) as Array<{ name: string; full_name: string | null }>;
|
|
if (users.length === 0) return; // silent — response already sent
|
|
const id = await ensureCustomerProfile(normalizedEmail, users[0].full_name);
|
|
customer = { id, name: users[0].full_name };
|
|
}
|
|
|
|
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." });
|
|
|
|
// Fetch the customer so we know which ERPNext user to update.
|
|
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) return res.status(400).json({ error: "Account not found for this reset link." });
|
|
|
|
// ERPNext is the password authority. Create the Website User if it somehow
|
|
// doesn't exist yet, then set the new password there.
|
|
await ensureWebsiteUser(customer.email, customer.name || customer.email.split("@")[0]);
|
|
await setWebsiteUserPassword(customer.email, password);
|
|
|
|
await pool.query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id]);
|
|
|
|
// Issue session cookie so they're logged in immediately
|
|
issueCustomerCookie(res, { customerId: customer.id, email: customer.email });
|
|
|
|
res.json({ success: true, customer: { id: customer.id, email: customer.email, name: customer.name } });
|
|
} catch (err: any) {
|
|
console.error("[portal-auth] reset-password error:", err);
|
|
const serverMsg = err?.body?._server_messages || err?.message || "";
|
|
if (/password/i.test(serverMsg)) {
|
|
return res.status(400).json({ error: "Password is too weak. Use a mix of uppercase, lowercase, numbers, and symbols." });
|
|
}
|
|
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
|
|
const erpCustomerName = await findErpCustomerName(normalizedEmail);
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Keep the PG profile row in sync so portal session routes have a record.
|
|
await ensureCustomerProfile(normalizedEmail, customer_name);
|
|
|
|
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;
|