'use strict' /** * Portal passwordless authentication. * * Exposes POST /portal/request-link — the customer types their email or phone, * we look them up in ERPNext, mint a 24 h customer JWT, and send the portal * URL via SMS + email. The portal's useMagicToken() composable reads the * token from the URL and hydrates the customer store. * * identifier ─► Customer (email_id | email_billing | cell_phone | tel_home * | legacy_customer_id | Customer.name) * │ * ├─► SMS: "Gigafibre — Accédez à votre portail: {link}" * └─► Email: HTML template with CTA button + full URL * * Security: * - **Anti-enumeration**: we always return 200 OK, whether the identifier * matched a customer or not. Prevents "is this email in your system?" * probes. The internal log still records the miss. * - **Rate limit**: 3 requests per 15 min per identifier (case-insensitive). * Uses an in-memory Map with a cleanup timer — survives restart loss * gracefully (worst case, a throttled user gets to try again after a * restart, which is fine). * - **Token TTL**: 24 h. After that, the user requests a new link. * - **JWT signature**: HS256 with cfg.JWT_SECRET. The portal never has the * secret, so it trusts the server's signature implicitly (the token * arrives over HTTPS from the user's own email/SMS). * * This endpoint replaces ERPNext's /login for portal customers. Staff still * authenticate via Authentik forwardAuth on ops.gigafibre.ca and erp.*; * customers never touch Authentik. */ const { log, json, parseBody, erpFetch, lookupCustomerByPhone } = require('./helpers') const { generateCustomerToken } = require('./magic-link') const cfg = require('./config') const PORTAL_URL = cfg.CLIENT_PORTAL_URL || 'https://portal.gigafibre.ca' const RATE_LIMIT_MAX = 3 const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000 const LINK_TTL_HOURS = 24 // identifier (lower-cased) → { count, windowStart } const rateLimits = new Map() function checkRateLimit (key) { const now = Date.now() const entry = rateLimits.get(key) if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { rateLimits.set(key, { count: 1, windowStart: now }) return { ok: true } } if (entry.count >= RATE_LIMIT_MAX) { return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - entry.windowStart) } } entry.count++ return { ok: true } } // Cull expired windows every 15 min so the Map doesn't grow unbounded. setInterval(() => { const now = Date.now() for (const [k, v] of rateLimits) { if (now - v.windowStart > RATE_LIMIT_WINDOW_MS) rateLimits.delete(k) } }, RATE_LIMIT_WINDOW_MS).unref() function erpQuery (doctype, filters, fields, limit = 1) { const url = `/api/resource/${encodeURIComponent(doctype)}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}&limit_page_length=${limit}` return erpFetch(url) } const CUSTOMER_FIELDS = ['name', 'customer_name', 'cell_phone', 'tel_home', 'email_id', 'email_billing'] async function lookupCustomer (raw) { const cleaned = String(raw).trim() if (!cleaned) return null // Email — try both email_id and email_billing if (cleaned.includes('@')) { const email = cleaned.toLowerCase() for (const field of ['email_id', 'email_billing']) { const r = await erpQuery('Customer', [[field, '=', email]], CUSTOMER_FIELDS) if (r.status === 200 && r.data?.data?.length) return r.data.data[0] } return null } // Explicit customer ID (ERPNext name or legacy id) if (/^(C-|CUST-|GI-)/i.test(cleaned) || /^\d{4,7}$/.test(cleaned)) { // Try legacy_customer_id const legacy = await erpQuery('Customer', [['legacy_customer_id', '=', cleaned]], CUSTOMER_FIELDS) if (legacy.status === 200 && legacy.data?.data?.length) return legacy.data.data[0] // Try direct Customer.name try { const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(cleaned)}?fields=${encodeURIComponent(JSON.stringify(CUSTOMER_FIELDS))}`) if (r.status === 200 && r.data?.data) return r.data.data } catch (e) { /* fall through */ } return null } // Phone — lookupCustomerByPhone returns minimal fields; re-fetch for email const match = await lookupCustomerByPhone(cleaned) if (!match) return null try { const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(match.name)}?fields=${encodeURIComponent(JSON.stringify(CUSTOMER_FIELDS))}`) if (r.status === 200 && r.data?.data) return r.data.data } catch (e) { /* use minimal match */ } return match } function redactEmail (email) { if (!email) return '' const [user, domain] = email.split('@') if (!domain) return '***' const u = user.length <= 2 ? user[0] + '*' : user[0] + '*'.repeat(Math.max(1, user.length - 2)) + user.slice(-1) return `${u}@${domain}` } function redactPhone (phone) { if (!phone) return '' const digits = phone.replace(/\D/g, '') if (digits.length < 4) return '***' return `***-***-${digits.slice(-4)}` } function loginEmailHtml (customerName, link) { return `

Bonjour ${customerName || ''},
Voici votre lien d'accès au portail client Gigafibre :
Ce lien est valide pour 24 heures. Si vous n'avez pas demandé cet accès, vous pouvez ignorer ce courriel.
Lien complet :${link}