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>
110 lines
3.5 KiB
JavaScript
110 lines
3.5 KiB
JavaScript
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('')
|
|
const customerName = ref('')
|
|
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) {
|
|
// Silent — router guard will push to /login when customerId is empty
|
|
error.value = null
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function clear () {
|
|
email.value = ''
|
|
customerId.value = ''
|
|
customerName.value = ''
|
|
error.value = null
|
|
}
|
|
|
|
return { email, customerId, customerName, loading, error, init, hydrateFromToken, clear }
|
|
})
|