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>
708 lines
40 KiB
Markdown
708 lines
40 KiB
Markdown
# 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](../architecture/overview.md)
|
||
> and [../architecture/data-model.md](../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=/`:
|
||
|
||
```js
|
||
// apps/client/quasar.config.js
|
||
extendViteConf (viteConf) {
|
||
viteConf.base = process.env.DEPLOY_BASE || '/assets/client-app/'
|
||
}
|
||
```
|
||
|
||
```bash
|
||
# 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
|
||
|
||
```bash
|
||
./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 to `portal.…/$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)
|
||
|
||
```js
|
||
// 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`:
|
||
|
||
```json
|
||
{
|
||
"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-|GI-)/i` or `^\d{4,7}$` | `Customer.legacy_customer_id` → direct `Customer.name` fetch |
|
||
| 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:
|
||
|
||
```js
|
||
// 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](../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:
|
||
|
||
```js
|
||
// 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:
|
||
|
||
```js
|
||
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](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`. See
|
||
`billing-payments.md` and memory `project_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
|
||
|
||
```bash
|
||
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`)
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
# 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 | grep 'Portal link'` — the log reports `sent via email` vs `NONE` |
|
||
| "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](../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 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-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 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](../roadmap.md).*
|
||
|
||
---
|
||
|
||
Back to [docs/README.md](../README.md) · [roadmap.md](../roadmap.md)
|