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:
parent
90f5f2eaa0
commit
2b04e6bd86
32
apps/client/src/api/auth-portal.js
Normal file
32
apps/client/src/api/auth-portal.js
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,18 @@
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
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__ || ''
|
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
|
||||||
|
|
||||||
export function authFetch (url, opts = {}) {
|
export function authFetch (url, opts = {}) {
|
||||||
|
|
@ -12,9 +25,10 @@ export function authFetch (url, opts = {}) {
|
||||||
opts.credentials = 'omit'
|
opts.credentials = 'omit'
|
||||||
}
|
}
|
||||||
return fetch(url, opts).then(res => {
|
return fetch(url, opts).then(res => {
|
||||||
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
|
// 401 from ERPNext means the service token is broken — fail loud in console,
|
||||||
window.location.reload()
|
// but don't nuke the user's tab. Their magic-link session is independent.
|
||||||
return new Response('{}', { status: 401 })
|
if (res.status === 401) {
|
||||||
|
console.warn('[portal] ERPNext returned 401 — check VITE_ERP_TOKEN')
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
|
|
@ -33,6 +47,17 @@ export async function getLoggedUser () {
|
||||||
return 'authenticated'
|
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 () {
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* Magic link token handling for payment return pages.
|
* Magic link helper 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.
|
* 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 { useRoute } from 'vue-router'
|
||||||
import { useCustomerStore } from 'src/stores/customer'
|
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 () {
|
export function useMagicToken () {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useCustomerStore()
|
const store = useCustomerStore()
|
||||||
|
|
||||||
const token = route.query.token || ''
|
// `expired` is a hint for the UI: "you arrived here with a token but we
|
||||||
let authenticated = false
|
// couldn't use it". Approximated by: URL had a token (route.query or the
|
||||||
let expired = false
|
// raw hash) but the store is still empty.
|
||||||
|
const urlHadToken =
|
||||||
|
!!route.query.token ||
|
||||||
|
(typeof window !== 'undefined' && window.location.hash.includes('token='))
|
||||||
|
|
||||||
if (token) {
|
const authenticated = !!store.customerId
|
||||||
const payload = decodeJwt(token)
|
const expired = urlHadToken && !authenticated
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
return { authenticated, expired, customerId: store.customerId }
|
return { authenticated, expired, customerId: store.customerId }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
apps/client/src/pages/LoginPage.vue
Normal file
156
apps/client/src/pages/LoginPage.vue
Normal 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 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é !</div>
|
||||||
|
<div v-if="redacted.length" class="text-body2 text-grey-7 q-mt-sm">
|
||||||
|
Vérifiez :
|
||||||
|
<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>
|
||||||
|
|
@ -33,14 +33,15 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useMagicToken } from 'src/composables/useMagicToken'
|
import { useMagicToken } from 'src/composables/useMagicToken'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const auth = useMagicToken()
|
const auth = useMagicToken()
|
||||||
const invoiceName = route.query.invoice || ''
|
const invoiceName = route.query.invoice || ''
|
||||||
|
|
||||||
function goToLogin () {
|
function goToLogin () {
|
||||||
window.location.href = 'https://id.gigafibre.ca/'
|
router.push({ name: 'login' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useMagicToken } from 'src/composables/useMagicToken'
|
import { useMagicToken } from 'src/composables/useMagicToken'
|
||||||
|
|
||||||
const auth = useMagicToken()
|
const auth = useMagicToken()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
function goToLogin () {
|
function goToLogin () {
|
||||||
window.location.href = 'https://id.gigafibre.ca/'
|
router.push({ name: 'login' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,8 @@
|
||||||
</div>
|
</div>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
<div class="q-gutter-sm">
|
<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" />
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -41,22 +36,15 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useMagicToken } from 'src/composables/useMagicToken'
|
import { useMagicToken } from 'src/composables/useMagicToken'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const auth = useMagicToken()
|
const auth = useMagicToken()
|
||||||
const invoiceName = route.query.invoice || ''
|
const invoiceName = route.query.invoice || ''
|
||||||
const sendingLink = ref(false)
|
|
||||||
const linkSent = ref(false)
|
|
||||||
|
|
||||||
function goToLogin () {
|
function goToLogin () {
|
||||||
window.location.href = 'https://id.gigafibre.ca/'
|
router.push({ name: 'login' })
|
||||||
}
|
|
||||||
|
|
||||||
async function requestMagicLink () {
|
|
||||||
// Redirect to the OTP/login flow on the portal
|
|
||||||
window.location.href = 'https://portal.gigafibre.ca/#/'
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,40 @@
|
||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
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 = [
|
const routes = [
|
||||||
|
// Standalone — no layout wrapper
|
||||||
|
{ path: '/login', name: 'login', component: () => import('pages/LoginPage.vue') },
|
||||||
|
|
||||||
|
// Main portal — wrapped in drawer + header layout
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('layouts/PortalLayout.vue'),
|
component: () => import('layouts/PortalLayout.vue'),
|
||||||
|
|
@ -22,7 +56,29 @@ const routes = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
|
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
|
||||||
routes,
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,60 @@ import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getPortalUser } from 'src/api/portal'
|
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', () => {
|
export const useCustomerStore = defineStore('customer', () => {
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const customerId = ref('')
|
const customerId = ref('')
|
||||||
|
|
@ -9,21 +63,47 @@ export const useCustomerStore = defineStore('customer', () => {
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(null)
|
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 () {
|
async function init () {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
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()
|
const user = await getPortalUser()
|
||||||
email.value = user.email
|
email.value = user.email
|
||||||
customerId.value = user.customer_id
|
customerId.value = user.customer_id
|
||||||
customerName.value = user.customer_name
|
customerName.value = user.customer_name
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to resolve customer:', e)
|
// Silent — router guard will push to /login when customerId is empty
|
||||||
error.value = e.message || 'Impossible de charger votre compte'
|
error.value = null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
# Traefik dynamic route: client.gigafibre.ca → ERPNext (no Authentik)
|
# Traefik dynamic route: client.gigafibre.ca → ERPNext (no Authentik)
|
||||||
#
|
#
|
||||||
# Purpose: Customer portal accessible without SSO.
|
# 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
|
# Deploy: copy to /opt/traefik/dynamic/ on 96.125.196.67
|
||||||
# scp traefik-client-portal.yml root@96.125.196.67:/opt/traefik/dynamic/
|
# scp traefik-client-portal.yml root@96.125.196.67:/opt/traefik/dynamic/
|
||||||
|
|
@ -21,7 +25,37 @@ http:
|
||||||
service: client-portal-svc
|
service: client-portal-svc
|
||||||
tls:
|
tls:
|
||||||
certResolver: letsencrypt
|
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
|
# Block /desk access for portal users
|
||||||
client-portal-block-desk:
|
client-portal-block-desk:
|
||||||
|
|
@ -37,6 +71,15 @@ http:
|
||||||
priority: 200
|
priority: 200
|
||||||
|
|
||||||
middlewares:
|
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
|
# Redirect /desk attempts to portal home
|
||||||
portal-redirect-home:
|
portal-redirect-home:
|
||||||
redirectRegex:
|
redirectRegex:
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,11 @@ legacy.invoice_item.service_id
|
||||||
|
|
||||||
### Points d'attention
|
### Points d'attention
|
||||||
|
|
||||||
- **Mots de passe legacy** = MD5 non-salé → forcer un reset via OTP email/SMS
|
- **Auth portail** = passwordless magic-link (depuis 2026-04-22) —
|
||||||
(`project_portal_auth.md`).
|
`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
|
- **Devices** rattachés aux **addresses** (Service Location), pas aux customers
|
||||||
(`feedback_device_hierarchy.md`).
|
(`feedback_device_hierarchy.md`).
|
||||||
- **Serials TPLG** ERPNext ≠ serials réels — matching via MAC
|
- **Serials TPLG** ERPNext ≠ serials réels — matching via MAC
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,9 @@ All 16 `/payments/*` hub endpoints ship; see
|
||||||
- [x] Webhook handler with signature verification (5-min tolerance) → `/webhook/stripe`
|
- [x] Webhook handler with signature verification (5-min tolerance) → `/webhook/stripe`
|
||||||
- [x] Payment links via SMS + email (`/payments/send-link`)
|
- [x] Payment links via SMS + email (`/payments/send-link`)
|
||||||
- [x] Magic-link redirect after Stripe return (`/payments/return` → portal)
|
- [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
|
- [ ] Online appointment booking
|
||||||
- [ ] Real-time tech tracking SMS
|
- [ ] 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}`)
|
- [ ] QR code on modem → subscriber dashboard (`msg.gigafibre.ca/q/{mac}`)
|
||||||
|
|
||||||
## Phase 5 — Advanced Features
|
## Phase 5 — Advanced Features
|
||||||
|
|
|
||||||
205
services/targo-hub/lib/portal-auth.js
Normal file
205
services/targo-hub/lib/portal-auth.js
Normal 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 :</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 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 :<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 }
|
||||||
|
|
@ -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('/conversations')) return conversation.handle(req, res, method, path, url)
|
||||||
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
|
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('/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]
|
// Lightweight tech mobile page: /t/{token}[/action]
|
||||||
if (path.startsWith('/t/')) return require('./lib/tech-mobile').route(req, res, method, path)
|
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)
|
if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user