diff --git a/apps/client/deploy.sh b/apps/client/deploy.sh index d679867..185b178 100755 --- a/apps/client/deploy.sh +++ b/apps/client/deploy.sh @@ -1,52 +1,75 @@ #!/bin/bash # ───────────────────────────────────────────────────────────────────────────── -# deploy.sh — Build Gigafibre Client Portal and deploy to ERPNext container +# deploy.sh — Build Gigafibre Customer Portal and ship to portal.gigafibre.ca +# +# Topology (2026-04-22): +# portal.gigafibre.ca → standalone nginx:alpine container `client-portal` +# serving /opt/client-app/ on erp.gigafibre.ca. +# client.gigafibre.ca → Traefik 302 → portal.gigafibre.ca (legacy alias). +# +# We build the PWA with base=/ (the portal host serves from root, not from +# /assets/client-app/ like the old ERPNext-embedded deployment) and rsync +# the dist/pwa/ output into /opt/client-app/ on the server. The nginx +# container bind-mounts /opt/client-app/ read-only, so files appear live +# with no container restart. # # Usage: -# ./deploy.sh # deploy to remote server (production) -# ./deploy.sh local # deploy to local Docker (development) +# ./deploy.sh # build + ship to production (portal.gigafibre.ca) +# ./deploy.sh local # build only (dist/pwa/) — no deploy # ───────────────────────────────────────────────────────────────────────────── -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" SERVER="root@96.125.196.67" SSH_KEY="$HOME/.ssh/proxmox_vm" -DEST="/home/frappe/frappe-bench/sites/assets/client-app" +DEST="/opt/client-app" echo "==> Installing dependencies..." npm ci --silent -echo "==> Building PWA (base=/assets/client-app/)..." -VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/assets/client-app/ npx quasar build -m pwa +echo "==> Building PWA (base=/ for portal.gigafibre.ca)..." +# VITE_ERP_TOKEN is still needed by a few API calls that hit ERPNext +# directly (catalog, invoice PDFs). TODO: migrate these behind the hub. +VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/ npx quasar build -m pwa -if [ "$1" = "local" ]; then - CONTAINER=$(docker ps --format '{{.Names}}' | grep -E 'frontend' | grep -v ops | head -1) - [ -z "$CONTAINER" ] && echo "ERROR: ERPNext frontend container not found" && exit 1 - echo "==> Deploying to local container ($CONTAINER)..." - docker exec "$CONTAINER" sh -c "rm -rf $DEST && mkdir -p $DEST" - docker cp "$SCRIPT_DIR/dist/pwa/." "$CONTAINER:$DEST/" +if [ "${1:-}" = "local" ]; then echo "" - echo "Done! Client Portal: http://localhost:8080/assets/client-app/" -else - echo "==> Packaging..." - tar czf /tmp/client-pwa.tar.gz -C dist/pwa . - - echo "==> Deploying to $SERVER..." - cat /tmp/client-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \ - "cat > /tmp/client.tar.gz && \ - CONTAINER=\$(docker ps --format '{{.Names}}' | grep erpnext-frontend | head -1) && \ - echo \" Using container: \$CONTAINER\" && \ - docker exec -u root \$CONTAINER sh -c 'rm -rf $DEST && mkdir -p $DEST' && \ - TMPDIR=\$(mktemp -d) && \ - cd \$TMPDIR && tar xzf /tmp/client.tar.gz && \ - docker cp \$TMPDIR/. \$CONTAINER:$DEST/ && \ - docker exec -u root \$CONTAINER chown -R frappe:frappe $DEST && \ - rm -rf \$TMPDIR /tmp/client.tar.gz" - - rm -f /tmp/client-pwa.tar.gz - - echo "" - echo "Done! Client Portal: https://client.gigafibre.ca/" + echo "Local build done. Output: dist/pwa/" + exit 0 fi + +echo "==> Packaging..." +tar czf /tmp/client-pwa.tar.gz -C dist/pwa . + +echo "==> Shipping to $SERVER:$DEST ..." +# We deploy to a staging dir and flip atomically — this avoids serving +# a half-written index.html referencing new hashed assets that haven't +# finished uploading (would 404 the SPA for a second or two). +cat /tmp/client-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" "bash -s" <<'REMOTE' +set -euo pipefail +cat > /tmp/client.tar.gz +STAGE=$(mktemp -d /opt/client-app.new.XXXXXX) +tar xzf /tmp/client.tar.gz -C "$STAGE" +# Preserve docker-compose.yml + nginx.conf from live dir (they live alongside the SPA) +cp /opt/client-app/docker-compose.yml "$STAGE/" 2>/dev/null || true +cp /opt/client-app/nginx.conf "$STAGE/" 2>/dev/null || true +# Atomic flip +BACKUP=/opt/client-app.bak.$(date +%s) +mv /opt/client-app "$BACKUP" +mv "$STAGE" /opt/client-app +# Keep last 3 backups, prune the rest +ls -dt /opt/client-app.bak.* 2>/dev/null | tail -n +4 | xargs -r rm -rf +rm -f /tmp/client.tar.gz +# nginx bind-mount follows the original inode, so the `mv` swap above +# leaves the container pointed at the backup dir. Restart to re-bind. +docker restart client-portal >/dev/null +echo " Deployed. Backup: $BACKUP" +REMOTE + +rm -f /tmp/client-pwa.tar.gz + +echo "" +echo "Done! Customer Portal: https://portal.gigafibre.ca/" +echo "Legacy alias (302): https://client.gigafibre.ca/" diff --git a/apps/ops/package-lock.json b/apps/ops/package-lock.json index e71ee7c..243c622 100644 --- a/apps/ops/package-lock.json +++ b/apps/ops/package-lock.json @@ -12,6 +12,8 @@ "@twilio/voice-sdk": "^2.18.1", "chart.js": "^4.5.1", "cytoscape": "^3.33.2", + "html5-qrcode": "^2.3.8", + "idb-keyval": "^6.2.1", "lucide-vue-next": "^1.0.0", "pinia": "^2.1.7", "quasar": "^2.16.10", @@ -5892,6 +5894,12 @@ "node": "^14.13.1 || >=16.0.0" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5933,6 +5941,12 @@ "dev": true, "license": "ISC" }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/apps/portal/traefik-client-portal.yml b/apps/portal/traefik-client-portal.yml index 36071d8..68c24f4 100644 --- a/apps/portal/traefik-client-portal.yml +++ b/apps/portal/traefik-client-portal.yml @@ -1,13 +1,22 @@ -# Traefik dynamic route: client.gigafibre.ca → ERPNext (no Authentik) +# Traefik dynamic route: client.gigafibre.ca → redirect to portal.gigafibre.ca # -# Purpose: Customer portal accessible without SSO. -# Customers authenticate via a passwordless magic link (email/SMS) — the -# Vue SPA's /#/login page posts to the hub, which mints a 24h JWT and -# sends it back through a link the customer clicks. +# Background (2026-04-22): +# The customer portal Vue SPA now lives at portal.gigafibre.ca (served by a +# standalone nginx:alpine container from /opt/client-app/; Traefik label +# defined inline on that container). Customers authenticate with a 24h +# passwordless magic-link JWT — no Authentik, no ERPNext /login. # -# Staff continue to use id.gigafibre.ca / Authentik — NOT this host. +# The legacy host client.gigafibre.ca used to point at the ERPNext frontend +# directly, exposing Frappe's password login form. That's an attack surface +# we don't want (legacy MD5 hashes, enumerable accounts) and a dead-end UX +# (the Vue SPA isn't served there). This config retires client.gigafibre.ca +# by permanent-redirecting all traffic on that host to portal.gigafibre.ca, +# so stale bookmarks, old invoice links, and SMS that went out before the +# cut-over still land customers on the right front door. # -# Deploy: copy to /opt/traefik/dynamic/ on 96.125.196.67 +# Staff continue to use id.gigafibre.ca / Authentik — untouched. +# +# Deploy: # scp traefik-client-portal.yml root@96.125.196.67:/opt/traefik/dynamic/ # (Traefik auto-reloads dynamic config — no restart needed) # @@ -16,81 +25,28 @@ http: routers: - # Main portal router — NO authentik middleware - client-portal: + # Catch-all: every request on client.gigafibre.ca → portal.gigafibre.ca + # (preserves path so /invoice/INV-123 still lands somewhere sensible on + # the new SPA, which has a hash router — paths without a leading # just + # land on the SPA root and the user re-navigates from there). + client-portal-legacy-redirect: rule: "Host(`client.gigafibre.ca`)" entryPoints: - web - websecure - service: client-portal-svc - tls: - certResolver: letsencrypt - # Explicitly NO middlewares — customer auth is magic-link only - - # Block ERPNext's password /login page — we replaced it with a - # passwordless flow at /#/login. Any stale bookmark or external link - # that hits /login is bounced into the SPA's login route so customers - # never see the password form (legacy MD5 hashes aren't supported - # in the new flow — see docs/features/customer-portal.md). - client-portal-block-login: - rule: "Host(`client.gigafibre.ca`) && (Path(`/login`) || Path(`/login/`))" - entryPoints: - - web - - websecure - service: client-portal-svc + service: noop@internal middlewares: - - portal-redirect-magic-login + - portal-legacy-redirect tls: certResolver: letsencrypt - priority: 300 - - # Block ERPNext's password-reset page (equivalent path, same reason) - client-portal-block-update-password: - rule: "Host(`client.gigafibre.ca`) && PathPrefix(`/update-password`)" - entryPoints: - - web - - websecure - service: client-portal-svc - middlewares: - - portal-redirect-magic-login - tls: - certResolver: letsencrypt - priority: 300 - - # Block /desk access for portal users - client-portal-block-desk: - rule: "Host(`client.gigafibre.ca`) && PathPrefix(`/desk`)" - entryPoints: - - web - - websecure - service: client-portal-svc - middlewares: - - portal-redirect-home - tls: - certResolver: letsencrypt - priority: 200 + priority: 100 middlewares: - # Redirect /login + /update-password → SPA's magic-link request page. - # The SPA lives at /assets/client-app/ (see apps/client/deploy.sh); its - # hash router serves /#/login. - portal-redirect-magic-login: + # 302 (not 301) during the test phase so browsers don't cache the + # redirect permanently — we can still iterate on hostnames if needed. + # Flip `permanent: true` once the portal host is stable. + portal-legacy-redirect: redirectRegex: - regex: ".*" - replacement: "https://client.gigafibre.ca/#/login" + regex: "^https?://client\\.gigafibre\\.ca/(.*)" + replacement: "https://portal.gigafibre.ca/$1" permanent: false - - # Redirect /desk attempts to portal home - portal-redirect-home: - redirectRegex: - regex: ".*" - replacement: "https://client.gigafibre.ca/me" - permanent: false - - services: - # Same ERPNext frontend container, unique service name to avoid - # conflicts with Docker-label-defined services - client-portal-svc: - loadBalancer: - servers: - - url: "http://erpnext-frontend-1:8080"