gigafibre-fsm/docs/features/customer-portal.md
louispaulb 30a867a326 fix(tech): restore Gemini-native scanner + port equipment UX into ops
The ops tech module at /ops/#/j/* had drifted from the field app in two ways:

1. Scanner — a prior "restoration" re-added html5-qrcode, but the
   design has always been native <input capture="environment"> → Gemini
   2.5 Flash via targo-hub /vision/barcodes (up to 3 codes) and
   /vision/equipment (structured labels, up to 5). Revert useScanner.js
   + ScanPage.vue + TechScanPage.vue to commit e50ea88 and drop
   html5-qrcode from both package.json + lockfiles. No JS barcode
   library, no camera stream, no polyfills.

2. Equipment UX — TechJobDetailPage.vue was a 186-line stub missing the
   Ajouter bottom-sheet (Scanner / Rechercher / Créer), the debounced
   SN-then-MAC search, the 5-field create dialog, Type + Priority
   selects on the info card, and the location-detail contact expansion.
   Port the full UX from apps/field/src/pages/JobDetailPage.vue (526
   lines) into the ops module (458 lines after consolidation).

Rebuilt and deployed both apps. Remote smoke test confirms 0 bundles
reference html5-qrcode and the new TechJobDetailPage.1075b3b8.js chunk
(16.7 KB vs ~5 KB stub) ships the equipment bottom-sheet strings.

Docs:

- docs/features/tech-mobile.md — new. Documents all three delivery
  surfaces (legacy SSR /t/{jwt}, transitional apps/field/, unified
  /ops/#/j/*), Gemini-native scanner pipeline, equipment UX, magic-link
  JWT, cutover plan. Replaces an earlier stub that incorrectly
  referenced html5-qrcode.
- docs/features/dispatch.md — new. Dispatch board, scheduling, tags,
  travel-time optimization, magic-link SMS, SSE updates.
- docs/features/customer-portal.md — new. Plan A passwordless magic-link
  at portal.gigafibre.ca, Stripe self-service, file inventory.
- docs/architecture/module-interactions.md — new. One-page call graph
  with sequence diagrams for the hot paths.
- docs/README.md — expanded module index (§2) now lists every deployed
  surface with URL + primary doc + primary code locations (was missing
  dispatch, tickets, équipe, rapports, telephony, network, agent-flows,
  OCR, every customer-portal page). New cross-module edge map in §4.
- docs/features/README.md + docs/architecture/README.md — cross-link
  all new docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:56:38 -04:00

40 KiB
Raw Permalink Blame History

Customer Portal — Architecture & Integration Guide

Authoritative reference for the Gigafibre customer portal: the Vue 3 + Quasar SPA at portal.gigafibre.ca, its passwordless magic-link authentication, and the standalone nginx:alpine container that serves it. Companion document to ../architecture/overview.md and ../architecture/data-model.md.

Last updated: 2026-04-22. Plan A (magic-link login replacing ERPNext /login) shipped today. See commits 2b04e6b and 7ac9a58 for the deploy-topology cutover.


1. Goals & Principles

The customer portal is the only front door a residential or commercial customer ever sees. It replaces the legacy PHP portal and the ERPNext /login password form; staff continue to use Authentik (ops.gigafibre.ca / erp.gigafibre.ca) and never touch the portal host.

Design tenets:

  1. Passwordless by default. The customer types an email or phone, the hub mails/SMSes a 24 h JWT, the SPA decodes it client-side. No password, no session cookie, no Authentik for customers — rationale documented in ~/.claude/projects/-Users-louispaul-Documents-testap/memory/project_portal_auth.md (legacy passwords are unsalted MD5 and cannot be trusted).
  2. Anti-enumeration by contract. POST /portal/request-link always returns HTTP 200 {"ok":true, "sent":[…]}. An unknown identifier returns sent: []. An attacker cannot use the endpoint to probe whether an email or phone is in our customer database.
  3. Rate limit the send surface. 3 requests per 15-minute window per identifier (case-insensitive). Survives restart loss gracefully — the Map is in-memory and repopulates on the next abuse attempt.
  4. Both channels, best effort. A customer with both email and phone gets the link on both — SMS failure does not block email, and vice versa. The sent: [...] array tells the UI which channels actually delivered so it can display redacted destinations.
  5. Two routes, one SPA. portal.gigafibre.ca is the canonical host. client.gigafibre.ca is a 307 redirect at the Traefik layer so stale bookmarks, SMS notifications from before the cutover, and old invoice links still land on the right place.
  6. Hash router, not path router. The SPA uses createWebHashHistory so the token survives behind the # and never reaches nginx. Also lets the single index.html serve every route without per-path nginx rules.
  7. Retire, don't migrate. ERPNext's /login is dead for customers — there is no "staff impersonation" path in the passwordless flow. Staff who need to act as a customer use the Ops "Send magic link" action on the client-detail page (implemented via POST /portal/request-link).

2. Topology & Deployment

2.1 Hosts & routing

                            ┌───────────────────────────────────────┐
                            │  Traefik (erp.gigafibre.ca:443)       │
                            │  *.gigafibre.ca wildcard → 96.125.196.67│
                            └───────────────────────────────────────┘
                                │                 │                │
                                ▼                 ▼                ▼
          ┌───────────────────────┐  ┌────────────────────┐  ┌──────────────┐
          │ portal.gigafibre.ca   │  │ client.gigafibre.ca│  │ erp.*/ops.*  │
          │   (canonical host)    │  │   (legacy alias)   │  │   (staff)    │
          ├───────────────────────┤  ├────────────────────┤  ├──────────────┤
          │ docker container      │  │ Traefik dynamic    │  │ ERPNext      │
          │   client-portal       │  │ route — 307 to     │  │ frontend +   │
          │   image nginx:alpine  │  │ portal.gigafibre.ca│  │ Authentik    │
          │   bind-mounts         │  │ /$1 (preserves     │  │ forwardAuth  │
          │   /opt/client-app/    │  │  path)             │  │              │
          │     ─ index.html      │  │                    │  │ NO customer  │
          │     ─ assets/*        │  │ (traefik-client-   │  │ /login form  │
          │     ─ nginx.conf      │  │  portal.yml)       │  │ — endpoint is│
          │     ─ docker-compose  │  │                    │  │ blocked by   │
          └───────────────────────┘  └────────────────────┘  │ redirect     │
                                                             └──────────────┘

                                              ▲
                                              │ magic-link JWT + REST
                                              │   (msg.gigafibre.ca)
                                              │
                                     ┌──────────────────┐
                                     │   targo-hub      │
                                     │   portal-auth.js │
                                     │   magic-link.js  │
                                     └──────────────────┘
                                              │
                                              │  resource REST
                                              ▼
                                     ┌──────────────────┐
                                     │ ERPNext backend  │
                                     │ (Customer, Sales │
                                     │  Invoice, Issue) │
                                     └──────────────────┘

2.2 Two distinct Vue builds used to coexist — no more

Before 2026-04-22 the same Vue SPA was built twice: once with base=/assets/client-app/ and dropped into the ERPNext public folder so ERPNext's frontend would serve it at client.gigafibre.ca/assets/client-app/; a second build ran for local dev. That's retired — we now build with base=/ and ship to a standalone nginx container.

apps/client/quasar.config.js keeps /assets/client-app/ as the fallback so a local quasar dev still works, but production always overrides with DEPLOY_BASE=/:

// apps/client/quasar.config.js
extendViteConf (viteConf) {
  viteConf.base = process.env.DEPLOY_BASE || '/assets/client-app/'
}
# apps/client/deploy.sh (prod build)
VITE_ERP_TOKEN="…" DEPLOY_BASE=/ npx quasar build -m pwa

2.3 deploy.sh — atomic swap + the inode gotcha

The deploy does three things remotely:

  1. Upload the fresh build into a staging directory (mktemp -d /opt/client-app.new.XXXXXX).
  2. mv the live /opt/client-app aside (/opt/client-app.bak.<epoch>), then mv the staging dir onto /opt/client-app. This is atomic — nginx never sees a half-written index.html referencing hashed assets that haven't finished uploading.
  3. Keep the three most recent backups, delete older ones.

Then the non-obvious part:

Docker bind mounts follow the inode, not the path. When docker run records a bind mount, it resolves /opt/client-app once and pins that inode. After the atomic mv swap, the container is still pointed at the old directory (now /opt/client-app.bak.<epoch>) — so you'd serve stale content from the backup until you recreate the mount.

Fix: docker restart client-portal at the end of the deploy. The restart re-resolves the bind and the container picks up the new inode.

That docker restart line is the entire reason deploy.sh needed an update today; previously we relied on the bind mount "just reflecting the new files" which only works when you edit them in place, not when you swap the entire directory.

2.4 Local build only

./deploy.sh local    # build with DEPLOY_BASE=/ into dist/pwa/, no upload

Handy when you want to test the PWA manifest / service worker path behaviour before shipping.


3. File Inventory

3.1 Front-end — apps/client/

Path Responsibility
deploy.sh Build + rsync + atomic swap + docker restart client-portal
quasar.config.js Vite base override (DEPLOY_BASE), hash router, PWA manifest
src/App.vue Root — calls store.init() once on mount
src/router/index.js Routes + navigation guard (pushes unauthenticated to /login)
src/layouts/PortalLayout.vue Drawer + header + cart badge, shows customer name
src/stores/customer.js Pinia store: hydrateFromToken, JWT decode, stripTokenFromUrl, init
src/stores/cart.js Pinia store for the public catalog cart
src/composables/useMagicToken.js Read-through: {authenticated, expired, customerId} for payment pages
src/composables/useSSE.js EventSource wrapper for msg.gigafibre.ca/sse (topic-based)
src/composables/useFormatters.js formatDate, formatMoney, formatShortDate, formatPrice
src/composables/useOTP.js OTP send/verify for catalog checkout existing-customer path
src/composables/useAddressSearch.js RQA address autocomplete wrapper
src/composables/usePolling.js Generic polling helper
src/api/auth-portal.js requestPortalLink() — posts to hub /portal/request-link
src/api/auth.js authFetch() wraps ERPNext REST with service token (VITE_ERP_TOKEN)
src/api/portal.js ERPNext REST client: invoices, tickets, profile, service locations, comms
src/api/payments.js Hub client: balance, methods, checkout, setup card, billing portal, PPA
src/api/catalog.js Hub client: catalog, checkout, address search, OTP
src/pages/*.vue One page per route (see §4)

3.2 Back-end — services/targo-hub/lib/

Path Responsibility
portal-auth.js POST /portal/request-link — lookup, mint JWT, dispatch SMS + email, rate limit
magic-link.js generateCustomerToken() — HS256 JWT with scope=customer, 24 h TTL
config.js CLIENT_PORTAL_URL, JWT_SECRET — wired to portal.gigafibre.ca by default
helpers.js lookupCustomerByPhone() — last-10-digit match across cell_phone, tel_home, tel_office
twilio.js sendSmsInternal() — used for the SMS channel
email.js sendEmail() — used for the email channel
checkout.js / payments.js Stripe endpoints that the portal hits from InvoiceDetailPage
conversation.js SSE topic conversations + customer:<id> for real-time inbox updates

3.3 Traefik — apps/portal/

Path Responsibility
traefik-client-portal.yml Dynamic route: Host(\client.gigafibre.ca`)→ 307 toportal.…/$1`
deploy-portal.sh scp the YAML to /opt/traefik/dynamic/client-portal.yml + smoke-test

4. Routes & Pages

The router uses createWebHashHistory, so every URL above the portal root looks like https://portal.gigafibre.ca/#/<path>. Route paths in French match the customer-facing UI vocabulary (/me, /panier, /paiement/merci, /commande/confirmation).

Path Name Purpose Endpoints
/login login Passwordless login form — email or phone input, sent state, rate banner hub POST /portal/request-link
/ dashboard "Bonjour, {name}" + unpaid count + open tickets + 5 most recent invoices ERPNext Sales Invoice, Issue; hub POST /payments/checkout
/invoices invoices Paginated table of Sales Invoices with download PDF action ERPNext Sales Invoice, frappe.utils.print_format.download_pdf
/invoices/:name invoice-detail Single invoice: summary, lines, taxes, pay CTA (card + Klarna), PDF ERPNext Sales Invoice/:name, printview.get_html_and_style; hub POST /payments/checkout-invoice
/tickets tickets Support ticket list + create dialog ERPNext Issue (list + create)
/tickets/:name ticket-detail Conversation thread + reply box ERPNext Issue/:name, Communication (list + post)
/messages messages All communications (SMS / Email / Phone) grouped by ticket or medium ERPNext Communication (direct + via Issues); hub SSE topic customer:<id>
/me account Customer info, service locations + monthly total, saved cards, PPA toggle ERPNext Customer/:name, Service Location, Service Subscription; hub /payments/balance, /payments/methods, /payments/setup, /payments/portal, /payments/toggle-ppa
/catalogue catalog Public add-on catalog (Internet / Téléphonie / Bundle / Équipement) hub GET /api/catalog
/panier cart Cart review, install scheduling, OTP flow for existing customers hub POST /api/otp/send, POST /api/otp/verify, POST /api/address-search, POST /api/checkout
/commande/confirmation order-success Post-checkout confirmation with order number + install notice hub GET /api/order/:id
/paiement/merci payment-success Stripe return (session mode) — "Paiement reçu" + link back to invoice — (return landing only)
/paiement/annule payment-cancel Stripe return when the user backs out — (return landing only)
/paiement/carte-ajoutee payment-card-added Stripe Setup Intent return — confirms the card is saved, nudges PPA — (return landing only)

4.1 Public routes (skip the nav guard)

// apps/client/src/router/index.js
const PUBLIC_ROUTES = new Set([
  'login',
  'catalog', 'cart', 'order-success',
  'payment-success', 'payment-cancel', 'payment-card-added',
])

Payment returns stay public so a customer whose JWT has expired still sees a human confirmation (plus a re-auth CTA) rather than a bounce. The catalog and cart are public so unauthenticated prospects can shop add-ons — the OTP flow in the cart escalates to auth only at checkout.


5. Authentication Flow

5.1 Sequence

Customer                Login Page                 targo-hub                ERPNext             Email/SMS
   │                       │                          │                        │                    │
   │── types email/phone ──▶                          │                        │                    │
   │                       │── POST /portal/          │                        │                    │
   │                       │   request-link ─────────▶│                        │                    │
   │                       │                          │── lookupCustomer ─────▶│                    │
   │                       │                          │◀── Customer row ───────│                    │
   │                       │                          │                                             │
   │                       │                          │── generateCustomerToken (HS256, 24h) ───────│
   │                       │                          │── sendSmsInternal ─────────────────────────▶│
   │                       │                          │── sendEmail ───────────────────────────────▶│
   │                       │◀── 200 {ok:true,         │                        │                    │
   │                       │    sent:[sms,email],     │                        │                    │
   │                       │    redacted:[…]} ────────│                        │                    │
   │◀── "Lien envoyé!" ────│                          │                        │                    │
   │                                                                                                │
   │                       (customer opens SMS / email)                                              │
   │◀───────────────────── https://portal.gigafibre.ca/#/?token=<JWT> ──────────────────────────────│
   │                                                                                                │
   │── click ──▶ SPA loads                                                                          │
   │            └─ router guard: to.query.token → store.hydrateFromToken()                          │
   │               └─ JWT parts[1] base64-decoded, exp checked, scope==='customer'                  │
   │               └─ customerStore.customerId = payload.sub                                        │
   │               └─ stripTokenFromUrl()  (history.replaceState)                                   │
   │            └─ dashboard renders                                                                │

5.2 JWT shape

Minted by generateCustomerToken(customerId, customerName, email, ttlHours=24) in services/targo-hub/lib/magic-link.js:

{
  "alg": "HS256", "typ": "JWT"
}
.
{
  "sub":   "C-00042",
  "scope": "customer",
  "name":  "Jean Tremblay",
  "email": "jean@example.com",
  "iat":   1713798000,
  "exp":   1713884400
}

Signed HS256 with cfg.JWT_SECRET (shared only between the hub processes — the portal never holds the secret). The portal trusts the signature implicitly because the token arrived over HTTPS via a channel the user already controls (their own inbox or SMS). Any tampering breaks signature verification the next time the user clicks the link; the currently-loaded SPA never re-verifies.

5.3 Identifier resolution

portal-auth.js → lookupCustomer(raw) handles three forms:

Input shape Lookup order
Contains @ Customer.email_idCustomer.email_billing (both lowercased)
Matches `^(C- CUST-
Anything else (phone) helpers.lookupCustomerByPhone() — last 10 digits matched against cell_phone/tel_home/tel_office

Returned record always includes name, customer_name, cell_phone, tel_home, email_id, email_billing so the sender knows which channels are available.

5.4 URL hygiene after hydration

stores/customer.js → stripTokenFromUrl() uses history.replaceState to rewrite the URL without ?token= as soon as the store is hydrated. This keeps the token out of:

  • Browser history (back button won't show the token).
  • Bookmarks ("add this page to bookmarks" captures the stripped URL).
  • Third-party referer headers (external links on subsequent pages).

The JWT still lives in the Pinia store for the rest of the tab's life; a hard refresh reloads the store from…nothing, and the router guard bounces back to /login unless the URL carries a token or a previous hydration put customerId into localStorage (the store does not persist — this is intentional; a refresh is a fresh auth attempt).

5.5 Subsequent calls

Two backends, two auth styles:

  • ERPNext REST (BASE_URL/api/resource/…, /api/method/…): served through authFetch() in src/api/auth.js, which attaches Authorization: token <VITE_ERP_TOKEN>. The token is a fixed service key baked in at build time; it's not the customer's JWT. The customer's identity is enforced at the query layer by always filtering on customer = store.customerId from the store.
  • Hub (msg.gigafibre.ca/payments/*, /api/*, /sse): currently called unauthenticated. Endpoints that need customer context receive customer as a body/path parameter. The hub trusts the portal origin. Upgrading these endpoints to verify the magic-link JWT is tracked as a hardening follow-up (see §10).

6. Anti-Enumeration Contract

POST /portal/request-link adheres strictly to the following response matrix. Callers (portal UI, staff-ops tools, any future integration) must treat 200 OK as the only success signal and must not branch on the identity of sent channels to decide whether the account exists.

Situation HTTP Body
Unknown identifier 200 {"ok":true, "sent":[]}
Known, only email on file 200 {"ok":true, "sent":["email"], "redacted":["j***n@example.com"]}
Known, only phone on file 200 {"ok":true, "sent":["sms"], "redacted":["***-***-1234"]}
Known, both channels 200 {"ok":true, "sent":["sms","email"], "redacted":["***-***-1234","j***n@…"]}
Known, both channels, both failed 200 {"ok":true, "sent":[]} (logged internally as delivery failure)
Empty body 400 {"error":"identifier_required"}
Rate limit hit 429 {"error":"rate_limit", "message":"Trop de tentatives. Réessayez dans N min.", "retry_after_sec":N}

Two downstream rules follow:

  1. The login page shows "Lien envoyé" even on sent: []. The copy says "Si votre compte existe, un lien vous a été envoyé" — the timing and the UI are identical to the happy path. A typo'd email looks indistinguishable from a genuine send.
  2. The internal log still records the miss (log('Portal request-link: no match for "…"')) so ops can spot fat-finger traffic vs. probing. The log is not served publicly.

Redaction rules (redactEmail, redactPhone):

  • Email: keep first + last char of local part, mask the middle, keep the domain (j***n@example.com).
  • Phone: ***-***-XXXX where XXXX is the last 4 digits of the raw cell_phone/tel_home field (not the E.164-normalized one).

7. Rate Limiting

A single Map<identifier, {count, windowStart}> in the hub process enforces 3 requests per 15 minutes (RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS in portal-auth.js). The key is rawIdentifier.trim().toLowerCase() so jean@example.com and JEAN@Example.com share a bucket.

Lifecycle:

// portal-auth.js
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 every 15 min so the Map stays small
setInterval(() => { /* drop entries older than window */ }, RATE_LIMIT_WINDOW_MS).unref()

7.1 Failure modes we accept

  • Process restart wipes the Map. Worst case a throttled attacker gets their window reset — still bounded by Twilio + Mailjet rate limits, and the internal Portal request-link log surfaces the abuse for ops.
  • Hub horizontal scale would duplicate the Map per replica. Today the hub is a single container so the Map is authoritative; if we scale out, move to Redis (tracked under the hub hardening line in ../roadmap.md).
  • Identifier rotation (attacker types slightly different strings): mitigated by Twilio/Mailjet per-destination throttles that kick in downstream — the attacker can't out-run our carrier tier.

7.2 Error surfacing to the UI

requestPortalLink() rethrows the 429 as a typed error:

// src/api/auth-portal.js
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
}

LoginPage.vue displays the message in an orange banner (q-banner bg-orange-1) without clearing the input — the user can read the message and adjust their identifier.


8. Cross-Module References

8.1 Billing — Stripe Checkout + Klarna

InvoiceDetailPage.vue exposes both payment rails:

payInvoice('card')    hub POST /payments/checkout-invoice { payment_method: 'card'   }
payInvoice('klarna')  hub POST /payments/checkout-invoice { payment_method: 'klarna' }

The hub returns a hosted Checkout URL; the SPA does window.location.href = url. Stripe's return URLs come back to /#/paiement/merci?invoice=…, /#/paiement/annule?invoice=…, and /#/paiement/carte-ajoutee (Setup Intent flow). All three live under PUBLIC_ROUTES so an expired token still gets a civilized landing.

Full lifecycle: billing-payments.md — especially §4 "Flux de paiement public" and §7 "Configuration & secrets" for Stripe keys and webhook wiring.

8.2 Messaging — Server-Sent Events

MessagesPage.vue subscribes to msg.gigafibre.ca/sse?topics=customer:<id> via useSSE. The hub broadcasts conversations and per-customer topics from lib/conversation.js (see lines 6870 of that file); every inbound SMS or email handled by the hub fires a broadcast, and the portal reloads fetchAllCommunications on receipt and shows a toast for inbound-direction messages.

The SPA's SSE connection is resilient: useSSE implements exponential backoff (1 s → 30 s) on disconnect. Read path details in apps/client/src/composables/useSSE.js.

8.3 Tickets — read-only window into ERPNext HD Ticket

TicketsPage and TicketDetailPage hit the stock ERPNext Issue doctype. Creation goes through POST /api/resource/Issue with customer, subject, description, issue_type='Support', priority='Medium'. Replies from the portal create a Communication tied to the Issue; staff see them in the Ops CRM pane. Cross-ref docs/architecture/data-model.md for the full Issue field list.

8.4 Catalog — shared SKUs with the quote wizard

CatalogPage lists the same Items the sales wizard exposes — Internet plans, phone service, bundles, equipment. Prices and requires_visit flags come from ERPNext Items via the hub's /api/catalog endpoint. Add-on purchases placed through /panier become Sales Order drafts that the dispatch queue picks up. See billing-payments.md §2 "App Frappe custom — gigafibre_utils" for the shared SKU catalog source of truth.

8.5 Contract acceptance

Two separate tracks, both documented elsewhere:

  • Residential JWT (promotion framing, 24 h link, same style as this portal's auth) — services/targo-hub/lib/contracts.js. See billing-payments.md and memory project_contract_acceptance.md.
  • Commercial DocuSealservices/targo-hub/lib/acceptance.js, documented in the same memory file.

Neither track reuses the portal's magic-link JWT today; the scope=customer token is strictly for browsing. A signed contract produces its own short-lived acceptance JWT minted by contracts.js.

8.6 Tech mobile app

The field tech interface lives at msg.gigafibre.ca/t/<tech-token> — a separate HS256 token scope (scope='all' or per-job) minted by magic-link.js. It shares the hub's HS256 primitive (signJwt) with the customer portal but not the token itself. There is no customer-facing touchpoint on the tech app; cross-ref is kept here only so future refactors remember the two scopes sign with the same secret.


9. Deploy & Operations

9.1 Ship a portal change

cd apps/client
./deploy.sh                      # build PWA + rsync + flip + docker restart

What the script prints on success:

  Deployed. Backup: /opt/client-app.bak.1713798123
Done! Customer Portal: https://portal.gigafibre.ca/
Legacy alias (302):    https://client.gigafibre.ca/

9.2 Ship a Traefik redirect change (client.gigafibre.ca)

cd apps/portal
./deploy-portal.sh               # scp YAML to /opt/traefik/dynamic/ + smoke test

Traefik auto-reloads anything under /opt/traefik/dynamic/ — no restart. The script also curls https://client.gigafibre.ca/login and prints the HTTP code so you know whether Let's Encrypt has provisioned the cert yet (first request may return 000 while the cert is being issued).

9.3 Roll back

ssh root@96.125.196.67
ls -dt /opt/client-app.bak.*     # pick the timestamp you want
mv /opt/client-app /opt/client-app.broken
mv /opt/client-app.bak.<epoch> /opt/client-app
docker restart client-portal      # same inode trap as deploy

deploy.sh keeps the three most recent backups (ls -dt … | tail -n +4 | xargs -r rm -rf), which is usually enough to roll back a same-day mistake.

9.4 Smoke tests

# SPA shell reachable
curl -sI https://portal.gigafibre.ca/ | head -5

# Legacy redirect preserves path
curl -sI https://client.gigafibre.ca/invoices | grep -iE 'location|http/'
#   expect:  HTTP/2 307
#   expect:  location: https://portal.gigafibre.ca/invoices

# Magic-link endpoint: anti-enumeration contract
curl -s -X POST https://msg.gigafibre.ca/portal/request-link \
  -H 'Content-Type: application/json' \
  -d '{"identifier":"does-not-exist@example.com"}'
#   expect:  {"ok":true,"sent":[]}

# Rate limit (send the same identifier 4 times quickly)
for i in 1 2 3 4; do
  curl -s -X POST https://msg.gigafibre.ca/portal/request-link \
    -H 'Content-Type: application/json' \
    -d '{"identifier":"rl-test@example.com"}' ; echo
done
#   expect 4th call:  429 + rate_limit body

9.5 Cache bust

The Quasar PWA service worker is configured with skipWaiting: true + clientsClaim: true + cleanupOutdatedCaches: true. A user who's been sitting on a stale tab usually picks up the new build on next nav. For a hard bust, append ?v=<epoch> to the URL or instruct the user to do a reload; the hash router handles the query string transparently.


10. Failure Modes

Symptom Likely cause Remedy
User clicks magic link, lands on /login JWT expired (>24 h) Re-request a link. Payment-return pages show a banner with a one-click re-auth button
User clicks magic link, lands on dashboard briefly, then bounces to /login on refresh Store not persisted; token already stripped from URL Expected — refresh is a fresh auth. Re-request or click from the original email again
"Lien envoyé" but no SMS arrives Twilio rejected the number (no cell_phone/tel_home on file, or number invalid) Check `docker logs targo-hub
"Lien envoyé" but no email arrives Mailjet rate limit / bounce Same log line. Investigate Mailjet dashboard for the destination
Dashboard renders blank after click VITE_ERP_TOKEN broken; ERPNext returns 401 Browser console shows [portal] ERPNext returned 401 — check VITE_ERP_TOKEN. Rebuild with the right token
client.gigafibre.ca serves the old portal Traefik dynamic config not reloaded / not placed in /opt/traefik/dynamic/ scp via deploy-portal.sh, then docker logs --tail 50 traefik
Deploy succeeds, but users still see old UI Docker bind mount still pinned to backup inode ssh … docker restart client-portal (deploy.sh does this; if you copied files manually, restart manually)
SSE never connects in /messages CSP or mixed-content block on msg.gigafibre.ca Check browser console — the nginx Content-Security-Policy must allow connect-src https://msg.gigafibre.ca
Hash router breaks behind corporate proxy Proxy strips the # fragment Issue a plain /login link (no token) — users self-request magic link from there
429 loop for a well-meaning user Same identifier typed 4 times inside 15 min Wait the window out, or restart the hub container to flush the Map

10.1 CSP for the hash router

The nginx container's config (/opt/client-app/nginx.conf, preserved across deploys by the cp docker-compose.yml nginx.conf lines in deploy.sh) must include at minimum:

connect-src 'self' https://msg.gigafibre.ca https://erp.gigafibre.ca;

The first permits hub SSE and REST; the second permits ERPNext REST via authFetch. If either is missing, the SPA loads but every data call fails.


11. Retirement Notes

11.1 ERPNext /login — retired for customers

  • The form is still reachable at erp.gigafibre.ca/login but is gated behind Authentik forwardAuth — non-staff accounts cannot authenticate.
  • client.gigafibre.ca/login (the old direct ERPNext frontend) no longer serves the form because the whole host is 307-redirected to portal.gigafibre.ca/login.
  • No migration of legacy MD5 password hashes is required. Ever. See memory project_portal_auth.md for rationale.

11.2 client.gigafibre.ca — legacy alias, not canonical

  • All outbound communications (email templates, SMS, invoice footers, QR codes on PDFs) now use https://portal.gigafibre.ca/#/…. See project_invoice_qr.md memory for the QR wiring.
  • The redirect in traefik-client-portal.yml is currently a 302 (permanent: false) while the cutover is still warm — we can flip to 301 once we're confident nothing else points at the old host. Tracking this in ../roadmap.md.
  • DNS: *.gigafibre.ca wildcard already resolves to 96.125.196.67. Let's Encrypt auto-provisions the cert for both hosts.

11.3 Authentik — not a factor for customers

Staff continue to use Authentik forwardAuth for Ops (ops.gigafibre.ca) and ERPNext (erp.gigafibre.ca). The portal host has no Authentik middleware; the JWT is the sole credential. Memory files reference_authentik.md and reference_authentik_client.md cover the staff side.


12. Glossary

  • Magic linkhttps://portal.gigafibre.ca/#/?token=<JWT>. The only authenticated entry point for customers. Minted by the hub, delivered via SMS + email.
  • Customer JWT — HS256 token with scope=customer, 24 h TTL, sub=<Customer.name>. Decoded client-side; never re-verified.
  • Anti-enumeration — The contract that every POST /portal/request-link returns 200 regardless of whether the identifier matched.
  • Hash router — Vue Router's createWebHashHistory mode. URL paths live after #, so the server always serves the same index.html.
  • Inode trap — The Docker bind-mount behaviour where mv-swapping a directory leaves the container pointed at the old inode. Solved by docker restart client-portal.
  • Legacy aliasclient.gigafibre.ca. Still resolves, still has a cert, but 307-redirects every request to the canonical host.
  • Service tokenVITE_ERP_TOKEN: a fixed ERPNext API key baked into the SPA bundle. Identifies the portal to ERPNext; not the customer. Customer identity is enforced at the query layer.

Last updated: 2026-04-22. Owners: Targo Platform team (louis@targo.ca). Related roadmap items: hub JWT verification on /payments/*, flip client.gigafibre.ca redirect to 301, Redis-backed rate limit for multi-replica hub. See ../roadmap.md.


Back to docs/README.md · roadmap.md