gigafibre-fsm/apps/client/src/api/auth.js
louispaulb 2b04e6bd86 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>
2026-04-22 13:25:28 -04:00

64 lines
2.1 KiB
JavaScript

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 = {}) {
opts.headers = {
...opts.headers,
Authorization: 'token ' + SERVICE_TOKEN,
}
opts.redirect = 'manual'
if (opts.method && opts.method !== 'GET') {
opts.credentials = 'omit'
}
return fetch(url, opts).then(res => {
// 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
})
}
export async function getLoggedUser () {
try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
headers: { Authorization: 'token ' + SERVICE_TOKEN },
})
if (res.ok) {
const data = await res.json()
return data.message || 'authenticated'
}
} catch {}
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 () {
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()
}