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>
85 lines
3.6 KiB
JavaScript
85 lines
3.6 KiB
JavaScript
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'),
|
|
children: [
|
|
{ path: '', name: 'dashboard', component: () => import('pages/DashboardPage.vue') },
|
|
{ path: 'invoices', name: 'invoices', component: () => import('pages/InvoicesPage.vue') },
|
|
{ path: 'invoices/:name', name: 'invoice-detail', component: () => import('pages/InvoiceDetailPage.vue') },
|
|
{ path: 'tickets', name: 'tickets', component: () => import('pages/TicketsPage.vue') },
|
|
{ path: 'tickets/:name', name: 'ticket-detail', component: () => import('pages/TicketDetailPage.vue') },
|
|
{ path: 'messages', name: 'messages', component: () => import('pages/MessagesPage.vue') },
|
|
{ path: 'me', name: 'account', component: () => import('pages/AccountPage.vue') },
|
|
{ path: 'catalogue', name: 'catalog', component: () => import('pages/CatalogPage.vue') },
|
|
{ path: 'panier', name: 'cart', component: () => import('pages/CartPage.vue') },
|
|
{ path: 'commande/confirmation', name: 'order-success', component: () => import('pages/OrderSuccessPage.vue') },
|
|
{ path: 'paiement/merci', name: 'payment-success', component: () => import('pages/PaymentSuccessPage.vue') },
|
|
{ path: 'paiement/annule', name: 'payment-cancel', component: () => import('pages/PaymentCancelPage.vue') },
|
|
{ path: 'paiement/carte-ajoutee', name: 'payment-card-added', component: () => import('pages/PaymentCardAddedPage.vue') },
|
|
],
|
|
},
|
|
]
|
|
|
|
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
|