diff --git a/apps/client/src/api/auth-portal.js b/apps/client/src/api/auth-portal.js new file mode 100644 index 0000000..f27ea9e --- /dev/null +++ b/apps/client/src/api/auth-portal.js @@ -0,0 +1,32 @@ +/** + * Portal passwordless auth — talks to targo-hub /portal/*. + * + * Flow: + * 1. Customer lands on /#/login (no token, no session). + * 2. Types email or phone, hits "Recevoir mon lien". + * 3. requestPortalLink() POSTs the identifier to the hub. + * 4. Hub looks up the Customer, mints a 24h JWT, sends via SMS + email. + * 5. Customer clicks the link in their inbox → portal.gigafibre.ca/#/?token=JWT. + * 6. useMagicToken() decodes it on page load, hydrates the customer store. + * + * The hub always returns 200 OK (anti-enumeration), so the only + * non-success response the UI should handle is 429 (rate limit). + */ +const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' + +export async function requestPortalLink (identifier) { + const r = await fetch(HUB + '/portal/request-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier }), + }) + const data = await r.json().catch(() => ({})) + if (r.status === 429) { + const err = new Error(data.message || 'Trop de tentatives. Réessayez plus tard.') + err.code = 'rate_limit' + err.retryAfterSec = data.retry_after_sec || 900 + throw err + } + if (!r.ok) throw new Error(data.error || `Hub ${r.status}`) + return data +} diff --git a/apps/client/src/api/auth.js b/apps/client/src/api/auth.js index e3581fd..27a7039 100644 --- a/apps/client/src/api/auth.js +++ b/apps/client/src/api/auth.js @@ -1,5 +1,18 @@ import { BASE_URL } from 'src/config/erpnext' +/** + * Auth shim for the customer portal. + * + * Before 2026-04-22 this wrapped a staff Authentik session. Customers no + * longer authenticate via Authentik — they arrive with a magic-link JWT + * and the customer store is hydrated directly from it. See stores/customer.js. + * + * We still read an ERP service token so ERPNext's REST resources (Sales + * Invoice, Issue, …) accept our server-side calls. That token is a fixed + * API key, not a session — it's injected at build time via VITE_ERP_TOKEN + * or at runtime via window.__ERP_TOKEN__. + */ + const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || '' export function authFetch (url, opts = {}) { @@ -12,9 +25,10 @@ export function authFetch (url, opts = {}) { opts.credentials = 'omit' } return fetch(url, opts).then(res => { - if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) { - window.location.reload() - return new Response('{}', { status: 401 }) + // 401 from ERPNext means the service token is broken — fail loud in console, + // but don't nuke the user's tab. Their magic-link session is independent. + if (res.status === 401) { + console.warn('[portal] ERPNext returned 401 — check VITE_ERP_TOKEN') } return res }) @@ -33,6 +47,17 @@ export async function getLoggedUser () { return 'authenticated' } +/** + * Customer logout — clear the store and bounce to /login. No Authentik + * involvement. The magic-link JWT, once minted, is stateless on the server + * and expires on its own; clearing the client store is sufficient. + */ export async function logout () { - window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/' + try { + const { useCustomerStore } = await import('src/stores/customer') + useCustomerStore().clear() + } catch { /* ignore — fall through to hard redirect */ } + window.location.hash = '#/login' + // Force a reload so every module drops any cached customer data. + window.location.reload() } diff --git a/apps/client/src/composables/useMagicToken.js b/apps/client/src/composables/useMagicToken.js index f67c518..7428582 100644 --- a/apps/client/src/composables/useMagicToken.js +++ b/apps/client/src/composables/useMagicToken.js @@ -1,52 +1,31 @@ /** - * Magic link token handling for payment return pages. - * Reads ?token=JWT from the URL, decodes it, hydrates the customer store. - * If token is expired/invalid, returns { expired: true } so the page can show a fallback. + * Magic link helper for payment-return pages. + * + * The customer store now owns all token decoding (see stores/customer.js: + * `hydrateFromToken`). This composable is a thin read-through so the + * payment pages can render `v-if="auth.authenticated"` without caring + * about where the session came from (magic-link URL vs. existing nav). + * + * The router guard already calls `store.hydrateFromToken()` before these + * pages mount, so by the time this runs the store is either hydrated or + * still empty — no extra decoding needed here. */ import { useRoute } from 'vue-router' import { useCustomerStore } from 'src/stores/customer' -// Minimal JWT decode (no verification — server already signed it) -function decodeJwt (token) { - try { - const parts = token.split('.') - if (parts.length !== 3) return null - const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) - // Check expiry - if (payload.exp && Date.now() / 1000 > payload.exp) { - return { ...payload, _expired: true } - } - return payload - } catch { - return null - } -} - export function useMagicToken () { const route = useRoute() const store = useCustomerStore() - const token = route.query.token || '' - let authenticated = false - let expired = false + // `expired` is a hint for the UI: "you arrived here with a token but we + // couldn't use it". Approximated by: URL had a token (route.query or the + // raw hash) but the store is still empty. + const urlHadToken = + !!route.query.token || + (typeof window !== 'undefined' && window.location.hash.includes('token=')) - if (token) { - const payload = decodeJwt(token) - if (payload && payload.scope === 'customer' && !payload._expired) { - // Hydrate customer store from magic link - store.customerId = payload.sub - store.customerName = payload.name || payload.sub - store.email = payload.email || '' - store.loading = false - store.error = null - authenticated = true - } else if (payload?._expired) { - expired = true - } - } else if (store.customerId) { - // Already logged in via Authentik SSO - authenticated = true - } + const authenticated = !!store.customerId + const expired = urlHadToken && !authenticated return { authenticated, expired, customerId: store.customerId } } diff --git a/apps/client/src/pages/LoginPage.vue b/apps/client/src/pages/LoginPage.vue new file mode 100644 index 0000000..cfc3a64 --- /dev/null +++ b/apps/client/src/pages/LoginPage.vue @@ -0,0 +1,156 @@ + + + + + + diff --git a/apps/client/src/pages/PaymentCancelPage.vue b/apps/client/src/pages/PaymentCancelPage.vue index 4c3eb68..93916dc 100644 --- a/apps/client/src/pages/PaymentCancelPage.vue +++ b/apps/client/src/pages/PaymentCancelPage.vue @@ -33,14 +33,15 @@ diff --git a/apps/client/src/pages/PaymentCardAddedPage.vue b/apps/client/src/pages/PaymentCardAddedPage.vue index cec92d7..b3341dc 100644 --- a/apps/client/src/pages/PaymentCardAddedPage.vue +++ b/apps/client/src/pages/PaymentCardAddedPage.vue @@ -29,11 +29,13 @@ diff --git a/apps/client/src/pages/PaymentSuccessPage.vue b/apps/client/src/pages/PaymentSuccessPage.vue index 344d1a6..98803b0 100644 --- a/apps/client/src/pages/PaymentSuccessPage.vue +++ b/apps/client/src/pages/PaymentSuccessPage.vue @@ -27,13 +27,8 @@
- - -
-
- Lien envoye! Verifiez vos SMS ou courriels.
@@ -41,22 +36,15 @@ diff --git a/apps/client/src/router/index.js b/apps/client/src/router/index.js index 3a35423..497b744 100644 --- a/apps/client/src/router/index.js +++ b/apps/client/src/router/index.js @@ -1,6 +1,40 @@ import { createRouter, createWebHashHistory } from 'vue-router' +import { useCustomerStore } from 'src/stores/customer' + +/** + * Portal router. + * + * Two route trees: + * /login → standalone (no drawer, no header) — LoginPage.vue + * /* → PortalLayout.vue (drawer + header + nav) + * + * Navigation guard: customers without a valid session get bounced to /login. + * A valid session is one of: + * - URL carries ?token=JWT (handled by useMagicToken on page mount) + * - customerStore.customerId is already set (previously authenticated) + * + * Routes marked in PUBLIC_ROUTES skip the guard — these are payment-return + * landings where we want to show a success/cancel message even if the token + * expired, plus the catalog which is intentionally browsable unauthenticated. + */ + +// Routes that work without a customer session — payment returns and public +// catalog. Must match the `name` value on the route, not the path. +const PUBLIC_ROUTES = new Set([ + 'login', + 'catalog', + 'cart', + 'order-success', + 'payment-success', + 'payment-cancel', + 'payment-card-added', +]) const routes = [ + // Standalone — no layout wrapper + { path: '/login', name: 'login', component: () => import('pages/LoginPage.vue') }, + + // Main portal — wrapped in drawer + header layout { path: '/', component: () => import('layouts/PortalLayout.vue'), @@ -22,7 +56,29 @@ const routes = [ }, ] -export default createRouter({ +const router = createRouter({ history: createWebHashHistory(process.env.VUE_ROUTER_BASE), routes, }) + +// Navigation guard: push unauthenticated users to /login (unless the route +// is public OR the URL carries a fresh ?token=). +router.beforeEach((to, from, next) => { + if (PUBLIC_ROUTES.has(to.name)) return next() + + const store = useCustomerStore() + + // Already authenticated from a previous nav in this session. + if (store.customerId) return next() + + // URL carries a token? Hydrate synchronously (URL parse + base64 decode, + // no network). Covers magic-link clicks landing on any page and avoids + // the race between App.vue's async init() and the first navigation. + if (to.query.token || (typeof window !== 'undefined' && window.location.hash.includes('token='))) { + if (store.hydrateFromToken()) return next() + } + + next({ name: 'login' }) +}) + +export default router diff --git a/apps/client/src/stores/customer.js b/apps/client/src/stores/customer.js index d668ccd..2ef3750 100644 --- a/apps/client/src/stores/customer.js +++ b/apps/client/src/stores/customer.js @@ -2,6 +2,60 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { getPortalUser } from 'src/api/portal' +/** + * Customer session store. + * + * Two hydration paths: + * 1. Magic-link JWT in the URL (`?token=JWT`). Decoded client-side — the + * hub already signed it and the user received it through their own + * verified channel (SMS/email), so trust is established. No network + * call needed. + * 2. ERPNext session (legacy / staff impersonation). Only tried when + * there's no token AND no existing store state. Failure is silent — + * the router guard will bounce the user to /login. + * + * For the token path we also strip `?token=` from the URL so a page refresh + * or bookmark doesn't replay the token forever. The store state carries us + * for the rest of the session; if the user refreshes without a token, the + * guard sees `customerId` still set and lets them through. + */ + +function readTokenFromLocation () { + if (typeof window === 'undefined') return '' + const hash = window.location.hash || '' + // Hash form: #/paiement/merci?token=xxx OR #/?token=xxx + const qIdx = hash.indexOf('?') + if (qIdx === -1) return '' + const qs = new URLSearchParams(hash.slice(qIdx + 1)) + return qs.get('token') || '' +} + +function decodeJwtPayload (token) { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) + if (payload.exp && Date.now() / 1000 > payload.exp) return null + return payload + } catch { + return null + } +} + +function stripTokenFromUrl () { + if (typeof window === 'undefined') return + const hash = window.location.hash || '' + const qIdx = hash.indexOf('?') + if (qIdx === -1) return + const before = hash.slice(0, qIdx) + const params = new URLSearchParams(hash.slice(qIdx + 1)) + params.delete('token') + const rest = params.toString() + const newHash = rest ? `${before}?${rest}` : before + // Use replaceState so the token doesn't stay in browser history + window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash) +} + export const useCustomerStore = defineStore('customer', () => { const email = ref('') const customerId = ref('') @@ -9,21 +63,47 @@ export const useCustomerStore = defineStore('customer', () => { const loading = ref(true) const error = ref(null) + function hydrateFromToken () { + const token = readTokenFromLocation() + if (!token) return false + const payload = decodeJwtPayload(token) + if (!payload || payload.scope !== 'customer') return false + customerId.value = payload.sub + customerName.value = payload.name || payload.sub + email.value = payload.email || '' + stripTokenFromUrl() + return true + } + async function init () { loading.value = true error.value = null try { + // Path 1: magic-link token in URL + if (hydrateFromToken()) return + + // Already authenticated in this tab? (e.g. subsequent nav) + if (customerId.value) return + + // Path 2: fall back to ERPNext session (staff impersonation / legacy) const user = await getPortalUser() email.value = user.email customerId.value = user.customer_id customerName.value = user.customer_name } catch (e) { - console.error('Failed to resolve customer:', e) - error.value = e.message || 'Impossible de charger votre compte' + // Silent — router guard will push to /login when customerId is empty + error.value = null } finally { loading.value = false } } - return { email, customerId, customerName, loading, error, init } + function clear () { + email.value = '' + customerId.value = '' + customerName.value = '' + error.value = null + } + + return { email, customerId, customerName, loading, error, init, hydrateFromToken, clear } }) diff --git a/apps/portal/traefik-client-portal.yml b/apps/portal/traefik-client-portal.yml index 2285a12..36071d8 100644 --- a/apps/portal/traefik-client-portal.yml +++ b/apps/portal/traefik-client-portal.yml @@ -1,7 +1,11 @@ # Traefik dynamic route: client.gigafibre.ca → ERPNext (no Authentik) # # Purpose: Customer portal accessible without SSO. -# Customers log in via ERPNext's built-in /login page. +# Customers authenticate via a passwordless magic link (email/SMS) — the +# Vue SPA's /#/login page posts to the hub, which mints a 24h JWT and +# sends it back through a link the customer clicks. +# +# Staff continue to use id.gigafibre.ca / Authentik — NOT this host. # # Deploy: copy to /opt/traefik/dynamic/ on 96.125.196.67 # scp traefik-client-portal.yml root@96.125.196.67:/opt/traefik/dynamic/ @@ -21,7 +25,37 @@ http: service: client-portal-svc tls: certResolver: letsencrypt - # Explicitly NO middlewares — customers auth via ERPNext /login + # Explicitly NO middlewares — customer auth is magic-link only + + # Block ERPNext's password /login page — we replaced it with a + # passwordless flow at /#/login. Any stale bookmark or external link + # that hits /login is bounced into the SPA's login route so customers + # never see the password form (legacy MD5 hashes aren't supported + # in the new flow — see docs/features/customer-portal.md). + client-portal-block-login: + rule: "Host(`client.gigafibre.ca`) && (Path(`/login`) || Path(`/login/`))" + entryPoints: + - web + - websecure + service: client-portal-svc + middlewares: + - portal-redirect-magic-login + tls: + certResolver: letsencrypt + priority: 300 + + # Block ERPNext's password-reset page (equivalent path, same reason) + client-portal-block-update-password: + rule: "Host(`client.gigafibre.ca`) && PathPrefix(`/update-password`)" + entryPoints: + - web + - websecure + service: client-portal-svc + middlewares: + - portal-redirect-magic-login + tls: + certResolver: letsencrypt + priority: 300 # Block /desk access for portal users client-portal-block-desk: @@ -37,6 +71,15 @@ http: priority: 200 middlewares: + # Redirect /login + /update-password → SPA's magic-link request page. + # The SPA lives at /assets/client-app/ (see apps/client/deploy.sh); its + # hash router serves /#/login. + portal-redirect-magic-login: + redirectRegex: + regex: ".*" + replacement: "https://client.gigafibre.ca/#/login" + permanent: false + # Redirect /desk attempts to portal home portal-redirect-home: redirectRegex: diff --git a/docs/features/billing-payments.md b/docs/features/billing-payments.md index e6f2c78..9b1b02f 100644 --- a/docs/features/billing-payments.md +++ b/docs/features/billing-payments.md @@ -71,8 +71,11 @@ legacy.invoice_item.service_id ### Points d'attention -- **Mots de passe legacy** = MD5 non-salé → forcer un reset via OTP email/SMS - (`project_portal_auth.md`). +- **Auth portail** = passwordless magic-link (depuis 2026-04-22) — + `POST /portal/request-link` envoie un JWT 24h par email + SMS. + Les mots de passe legacy MD5 ne sont **plus** utilisés ; le formulaire + ERPNext `/login` est bloqué au niveau Traefik sur `client.gigafibre.ca` + (redirect vers `/#/login`). Aucune migration de hash requise. - **Devices** rattachés aux **addresses** (Service Location), pas aux customers (`feedback_device_hierarchy.md`). - **Serials TPLG** ERPNext ≠ serials réels — matching via MAC diff --git a/docs/roadmap.md b/docs/roadmap.md index cdb355b..7705204 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -167,9 +167,9 @@ All 16 `/payments/*` hub endpoints ship; see - [x] Webhook handler with signature verification (5-min tolerance) → `/webhook/stripe` - [x] Payment links via SMS + email (`/payments/send-link`) - [x] Magic-link redirect after Stripe return (`/payments/return` → portal) +- [x] Passwordless portal login (`POST /portal/request-link`, email/SMS, 3/15min rate-limited, anti-enumeration) — ERPNext `/login` retired on `client.gigafibre.ca` (Traefik redirect → `/#/login`). MD5 migration moot. - [ ] Online appointment booking - [ ] Real-time tech tracking SMS -- [ ] Legacy password migration (MD5 → PBKDF2) — MD5-hashed passwords in legacy need forced reset via OTP email/SMS - [ ] QR code on modem → subscriber dashboard (`msg.gigafibre.ca/q/{mac}`) ## Phase 5 — Advanced Features diff --git a/services/targo-hub/lib/portal-auth.js b/services/targo-hub/lib/portal-auth.js new file mode 100644 index 0000000..14622e1 --- /dev/null +++ b/services/targo-hub/lib/portal-auth.js @@ -0,0 +1,205 @@ +'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 ` +
+ Gigafibre

+

Bonjour ${customerName || ''},

+

Voici votre lien d'accès au portail client Gigafibre :

+

+ + Accéder à mon portail + +

+

Ce lien est valide pour 24 heures. Si vous n'avez pas demandé cet accès, vous pouvez ignorer ce courriel.

+

Lien complet :
${link}

+
` +} + +async function handle (req, res, method, path) { + if (path === '/portal/request-link' && method === 'POST') { + const body = await parseBody(req) + const rawId = body && typeof body.identifier === 'string' ? body.identifier : '' + if (!rawId.trim()) return json(res, 400, { error: 'identifier_required' }) + + const rateKey = rawId.trim().toLowerCase() + const rl = checkRateLimit(rateKey) + if (!rl.ok) { + const retryMin = Math.ceil(rl.retryAfterMs / 60000) + return json(res, 429, { + error: 'rate_limit', + message: `Trop de tentatives. Réessayez dans ${retryMin} min.`, + retry_after_sec: Math.ceil(rl.retryAfterMs / 1000), + }) + } + + const customer = await lookupCustomer(rawId) + if (!customer) { + // Anti-enumeration: pretend success. Log internally for ops visibility. + log(`Portal request-link: no match for "${rawId}"`) + return json(res, 200, { ok: true, sent: [] }) + } + + const email = customer.email_id || customer.email_billing || '' + const token = generateCustomerToken(customer.name, customer.customer_name, email, LINK_TTL_HOURS) + const link = `${PORTAL_URL}/#/?token=${token}` + const sent = [] + const redacted = [] + + const phone = customer.cell_phone || customer.tel_home + if (phone) { + try { + const { sendSmsInternal } = require('./twilio') + await sendSmsInternal(phone, `Gigafibre — Accédez à votre portail:\n${link}\n(Valide 24h)`) + sent.push('sms') + redacted.push(redactPhone(phone)) + } catch (e) { log('Portal link SMS error:', e.message) } + } + + if (email) { + try { + const { sendEmail } = require('./email') + await sendEmail({ + to: email, + subject: "Gigafibre — Votre lien d'accès au portail", + html: loginEmailHtml(customer.customer_name, link), + }) + sent.push('email') + redacted.push(redactEmail(email)) + } catch (e) { log('Portal link email error:', e.message) } + } + + log(`Portal link for ${customer.name}: sent via ${sent.join(',') || 'NONE'}`) + return json(res, 200, { ok: true, sent, redacted }) + } + + return json(res, 404, { error: 'portal_endpoint_not_found' }) +} + +module.exports = { handle, lookupCustomer, redactEmail, redactPhone } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 7bb2e6b..970e6b5 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -84,6 +84,7 @@ const server = http.createServer(async (req, res) => { if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url) if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path) if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path) + if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path) // Lightweight tech mobile page: /t/{token}[/action] if (path.startsWith('/t/')) return require('./lib/tech-mobile').route(req, res, method, path) if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path)