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 @@
+
+
+
+
+
+

+
Portail client
+
+ Entrez votre courriel ou téléphone pour recevoir un lien d'accès
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Lien valide 24 h. Pas de mot de passe requis.
+
+
+
+
+
+
+
Lien envoyé !
+
+
+ Si votre compte existe, un lien vous a été envoyé.
+ Vérifiez vos SMS et courriels.
+
+
+
+
+
+
+
+
+
+ {{ rateLimitMsg }}
+
+
+
+
+
+
+
+
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 `
+
+

+
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)