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>
40 KiB
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 standalonenginx:alpinecontainer 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 commits2b04e6band7ac9a58for 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:
- 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). - Anti-enumeration by contract.
POST /portal/request-linkalways returnsHTTP 200 {"ok":true, "sent":[…]}. An unknown identifier returnssent: []. An attacker cannot use the endpoint to probe whether an email or phone is in our customer database. - 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.
- 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. - Two routes, one SPA.
portal.gigafibre.cais the canonical host.client.gigafibre.cais 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. - Hash router, not path router. The SPA uses
createWebHashHistoryso the token survives behind the#and never reaches nginx. Also lets the singleindex.htmlserve every route without per-path nginx rules. - Retire, don't migrate. ERPNext's
/loginis 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 viaPOST /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:
- Upload the fresh build into a staging directory (
mktemp -d /opt/client-app.new.XXXXXX). mvthe live/opt/client-appaside (/opt/client-app.bak.<epoch>), thenmvthe staging dir onto/opt/client-app. This is atomic — nginx never sees a half-writtenindex.htmlreferencing hashed assets that haven't finished uploading.- 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 runrecords a bind mount, it resolves/opt/client-apponce and pins that inode. After the atomicmvswap, 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-portalat 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_id → Customer.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 throughauthFetch()insrc/api/auth.js, which attachesAuthorization: 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 oncustomer = store.customerIdfrom the store. - Hub (
msg.gigafibre.ca/payments/*,/api/*,/sse): currently called unauthenticated. Endpoints that need customer context receivecustomeras 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:
- 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. - 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:
***-***-XXXXwhereXXXXis the last 4 digits of the rawcell_phone/tel_homefield (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-linklog 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 68–70 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. Seebilling-payments.mdand memoryproject_contract_acceptance.md. - Commercial DocuSeal —
services/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/loginbut 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 toportal.gigafibre.ca/login.- No migration of legacy MD5 password hashes is required. Ever. See
memory
project_portal_auth.mdfor 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/#/…. Seeproject_invoice_qr.mdmemory for the QR wiring. - The redirect in
traefik-client-portal.ymlis currently a302(permanent: false) while the cutover is still warm — we can flip to301once we're confident nothing else points at the old host. Tracking this in ../roadmap.md. - DNS:
*.gigafibre.cawildcard already resolves to96.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 link —
https://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-linkreturns 200 regardless of whether the identifier matched. - Hash router — Vue Router's
createWebHashHistorymode. URL paths live after#, so the server always serves the sameindex.html. - Inode trap — The Docker bind-mount behaviour where
mv-swapping a directory leaves the container pointed at the old inode. Solved bydocker restart client-portal. - Legacy alias —
client.gigafibre.ca. Still resolves, still has a cert, but 307-redirects every request to the canonical host. - Service token —
VITE_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