feat(portal): passwordless magic-link login — retire ERPNext /login

Customers no longer authenticate with passwords. A POST to the hub's
/portal/request-link mints a 24h customer-scoped JWT and sends it via
email + SMS; the /#/login Vue page sits on top of this and a navigation
guard hydrates the Pinia store from the token on arrival.

Why now: legacy customer passwords are unsalted MD5 from the old PHP
system. Migrating hashes to PBKDF2 would still require a forced reset
for every customer, so it's simpler to drop passwords entirely. The
earlier Authentik forwardAuth attempt was already disabled on
client.gigafibre.ca; this removes the last vestige of ERPNext's
password form from the customer-facing path.

Hub changes:
  - services/targo-hub/lib/portal-auth.js (new) — POST /portal/request-link
    • 3-requests / 15-min per identifier rate limit (in-memory Map + timer)
    • Lookup by email (email_id + email_billing), customer id (legacy +
      direct name), or phone (cell + tel_home)
    • Anti-enumeration: always 200 OK with redacted contact hint
    • Email template with CTA button + raw URL fallback; SMS short form
  - services/targo-hub/server.js — mount the new /portal/* router

Client changes:
  - apps/client/src/pages/LoginPage.vue (new) — standalone full-page,
    single identifier input, success chips, rate-limit banner
  - apps/client/src/api/auth-portal.js (new) — thin fetch wrapper
  - apps/client/src/stores/customer.js — hydrateFromToken() sync decoder,
    stripTokenFromUrl (history.replaceState), init() silent Authentik
    fallback preserved for staff impersonation
  - apps/client/src/router/index.js — PUBLIC_ROUTES allowlist + guard
    that hydrates from URL token before redirecting
  - apps/client/src/api/auth.js — logout() clears store + bounces to
    /#/login (no more Authentik redirect); 401 in authFetch is warn-only
  - apps/client/src/composables/useMagicToken.js — thin read-through to
    the store (no more independent decoding)
  - PaymentSuccess/Cancel/CardAdded pages — goToLogin() uses router,
    not window.location to id.gigafibre.ca

Infra:
  - apps/portal/traefik-client-portal.yml — block /login and
    /update-password on client.gigafibre.ca, redirect to /#/login.
    Any stale bookmark or external link lands on the Vue page, not
    ERPNext's password form.

Docs:
  - docs/roadmap.md — Phase 4 checkbox flipped; MD5 migration item retired
  - docs/features/billing-payments.md — replace MD5 reset note with
    magic-link explainer

Online appointment booking (Plan B from the same discussion) is queued
for a follow-up session; this commit is Plan A only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-22 13:25:28 -04:00
parent 90f5f2eaa0
commit 2b04e6bd86
14 changed files with 642 additions and 71 deletions

View File

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

View File

@ -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()
}

View File

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

View File

@ -0,0 +1,156 @@
<!--
LoginPage passwordless portal login via magic link.
The only entry point for customers who don't have a ?token= in their URL.
Takes an email or phone, asks the hub to send a 24h magic link via SMS +
email, then shows a success state with redacted destinations so the user
knows where to look.
Failure modes surfaced to the user:
- Empty input inline field error
- Rate limit (429) banner with retry countdown
- Network error toast
- No match still shown as success (anti-enumeration). If sent array is
empty, we say "Lien envoyé" but no destinations matches the hub's
behaviour. This is intentional; legitimate users who typo their address
will see "no channels" and retry with the right one.
See docs/features/billing-payments.md for the full Stripe + magic-link
flow this page plugs into.
-->
<template>
<q-page class="flex flex-center" padding>
<div class="login-card">
<div class="text-center q-mb-lg">
<img src="https://store.targo.ca/clients/images/logo_trans_noir.png"
alt="Gigafibre" style="max-width: 180px" />
<div class="text-h5 text-weight-bold q-mt-md">Portail client</div>
<div class="text-body2 text-grey-7 q-mt-xs">
Entrez votre courriel ou téléphone pour recevoir un lien d'accès
</div>
</div>
<!-- Before send -->
<div v-if="!sent">
<q-input
v-model="identifier"
outlined
label="Courriel ou téléphone"
autofocus
:error="!!errorMsg"
:error-message="errorMsg"
:disable="loading"
@keyup.enter="submit"
>
<template #prepend>
<q-icon :name="isEmail ? 'email' : 'phone'" />
</template>
</q-input>
<q-btn
class="full-width q-mt-md"
color="primary"
unelevated
size="lg"
label="Recevoir mon lien"
icon="send"
:loading="loading"
:disable="!identifier.trim()"
@click="submit"
/>
<div class="text-caption text-grey-6 q-mt-md text-center">
Lien valide 24&nbsp;h. Pas de mot de passe requis.
</div>
</div>
<!-- After send -->
<div v-else class="text-center">
<q-icon name="mark_email_read" color="positive" size="64px" />
<div class="text-h6 q-mt-md">Lien envoyé&nbsp;!</div>
<div v-if="redacted.length" class="text-body2 text-grey-7 q-mt-sm">
Vérifiez&nbsp;:
<div class="q-mt-xs">
<span v-for="(r, i) in redacted" :key="i" class="q-mr-sm">
<q-chip dense outline color="grey-7" :label="r" />
</span>
</div>
</div>
<div v-else class="text-body2 text-grey-7 q-mt-sm">
Si votre compte existe, un lien vous a été envoyé.
Vérifiez vos SMS et courriels.
</div>
<q-btn
flat color="primary" class="q-mt-lg" label="Envoyer à nouveau"
icon="replay" @click="sent = false" no-caps
/>
</div>
<!-- Rate-limit banner -->
<q-banner v-if="rateLimited" rounded dense class="bg-orange-1 q-mt-md">
<template #avatar>
<q-icon name="schedule" color="warning" />
</template>
<div class="text-body2">{{ rateLimitMsg }}</div>
</q-banner>
</div>
</q-page>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
import { requestPortalLink } from 'src/api/auth-portal'
const $q = useQuasar()
const identifier = ref('')
const loading = ref(false)
const sent = ref(false)
const redacted = ref([])
const errorMsg = ref('')
const rateLimited = ref(false)
const rateLimitMsg = ref('')
const isEmail = computed(() => identifier.value.includes('@'))
async function submit () {
const raw = identifier.value.trim()
if (!raw) {
errorMsg.value = 'Entrez un courriel ou un numéro de téléphone'
return
}
errorMsg.value = ''
rateLimited.value = false
loading.value = true
try {
const r = await requestPortalLink(raw)
redacted.value = r.redacted || []
sent.value = true
} catch (e) {
if (e.code === 'rate_limit') {
rateLimited.value = true
rateLimitMsg.value = e.message
} else {
$q.notify({
type: 'negative',
message: 'Erreur réseau. Réessayez dans un moment.',
timeout: 4000,
})
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-card {
background: #fff;
border-radius: 12px;
padding: 32px;
max-width: 440px;
width: 100%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
</style>

View File

@ -33,14 +33,15 @@
</template>
<script setup>
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const route = useRoute()
const router = useRouter()
const auth = useMagicToken()
const invoiceName = route.query.invoice || ''
function goToLogin () {
window.location.href = 'https://id.gigafibre.ca/'
router.push({ name: 'login' })
}
</script>

View File

@ -29,11 +29,13 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const auth = useMagicToken()
const router = useRouter()
function goToLogin () {
window.location.href = 'https://id.gigafibre.ca/'
router.push({ name: 'login' })
}
</script>

View File

@ -27,13 +27,8 @@
</div>
</q-banner>
<div class="q-gutter-sm">
<q-btn color="primary" unelevated label="Se connecter" icon="login"
<q-btn color="primary" unelevated label="Recevoir mon lien d'acces" icon="login"
@click="goToLogin" />
<q-btn outline color="primary" label="Envoyer un lien d'acces" icon="link"
:loading="sendingLink" @click="requestMagicLink" />
</div>
<div v-if="linkSent" class="text-positive text-body2 q-mt-sm">
<q-icon name="check" /> Lien envoye! Verifiez vos SMS ou courriels.
</div>
</div>
</div>
@ -41,22 +36,15 @@
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const route = useRoute()
const router = useRouter()
const auth = useMagicToken()
const invoiceName = route.query.invoice || ''
const sendingLink = ref(false)
const linkSent = ref(false)
function goToLogin () {
window.location.href = 'https://id.gigafibre.ca/'
}
async function requestMagicLink () {
// Redirect to the OTP/login flow on the portal
window.location.href = 'https://portal.gigafibre.ca/#/'
router.push({ name: 'login' })
}
</script>

View File

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

View File

@ -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 }
})

View File

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

View File

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

View File

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

View File

@ -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 `
<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px">
<img src="https://store.targo.ca/clients/images/logo_trans_noir.png" width="160" alt="Gigafibre" /><br><br>
<p>Bonjour ${customerName || ''},</p>
<p>Voici votre lien d'accès au portail client Gigafibre&nbsp;:</p>
<p style="text-align:center;margin:24px 0">
<a href="${link}" style="display:inline-block;padding:12px 32px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:bold">
Accéder à mon portail
</a>
</p>
<p style="color:#888;font-size:13px">Ce lien est valide pour 24&nbsp;heures. Si vous n'avez pas demandé cet accès, vous pouvez ignorer ce courriel.</p>
<p style="color:#888;font-size:12px;word-break:break-all">Lien complet&nbsp;:<br><code>${link}</code></p>
</div>`
}
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 }

View File

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