fix(portal): deploy Vue SPA to portal.gigafibre.ca, retire client.gigafibre.ca
Topology clarification: - portal.gigafibre.ca = standalone nginx container serving /opt/client-app/ (the actual Vue SPA). This is the real customer portal. - client.gigafibre.ca = ERPNext frontend (exposes Frappe's password login form — dead-end UX, legacy MD5 attack surface). Changes: - apps/client/deploy.sh: target /opt/client-app/ directly with DEPLOY_BASE=/ (was uploading into ERPNext's /assets/client-app/, which nothing serves). Atomic stage-and-swap + docker restart so the nginx bind-mount picks up the new inode. - apps/portal/traefik-client-portal.yml: replace per-path /login and /desk blocks on client.gigafibre.ca with a catch-all 307 to portal.gigafibre.ca. Old bookmarks, old invoice links, and in-flight SMS all end up on the Vue SPA instead of Frappe's password page. - apps/ops/package-lock.json: sync to include html5-qrcode transitive deps so `npm ci` in deploy.sh works from a clean checkout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2b04e6bd86
commit
7ac9a582c6
|
|
@ -1,52 +1,75 @@
|
||||||
#!/bin/bash
|
#!/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:
|
# Usage:
|
||||||
# ./deploy.sh # deploy to remote server (production)
|
# ./deploy.sh # build + ship to production (portal.gigafibre.ca)
|
||||||
# ./deploy.sh local # deploy to local Docker (development)
|
# ./deploy.sh local # build only (dist/pwa/) — no deploy
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
SERVER="root@96.125.196.67"
|
SERVER="root@96.125.196.67"
|
||||||
SSH_KEY="$HOME/.ssh/proxmox_vm"
|
SSH_KEY="$HOME/.ssh/proxmox_vm"
|
||||||
DEST="/home/frappe/frappe-bench/sites/assets/client-app"
|
DEST="/opt/client-app"
|
||||||
|
|
||||||
echo "==> Installing dependencies..."
|
echo "==> Installing dependencies..."
|
||||||
npm ci --silent
|
npm ci --silent
|
||||||
|
|
||||||
echo "==> Building PWA (base=/assets/client-app/)..."
|
echo "==> Building PWA (base=/ for portal.gigafibre.ca)..."
|
||||||
VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/assets/client-app/ npx quasar build -m pwa
|
# 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
|
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/"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done! Client Portal: http://localhost:8080/assets/client-app/"
|
echo "Local build done. Output: dist/pwa/"
|
||||||
else
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Packaging..."
|
echo "==> Packaging..."
|
||||||
tar czf /tmp/client-pwa.tar.gz -C dist/pwa .
|
tar czf /tmp/client-pwa.tar.gz -C dist/pwa .
|
||||||
|
|
||||||
echo "==> Deploying to $SERVER..."
|
echo "==> Shipping to $SERVER:$DEST ..."
|
||||||
cat /tmp/client-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \
|
# We deploy to a staging dir and flip atomically — this avoids serving
|
||||||
"cat > /tmp/client.tar.gz && \
|
# a half-written index.html referencing new hashed assets that haven't
|
||||||
CONTAINER=\$(docker ps --format '{{.Names}}' | grep erpnext-frontend | head -1) && \
|
# finished uploading (would 404 the SPA for a second or two).
|
||||||
echo \" Using container: \$CONTAINER\" && \
|
cat /tmp/client-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" "bash -s" <<'REMOTE'
|
||||||
docker exec -u root \$CONTAINER sh -c 'rm -rf $DEST && mkdir -p $DEST' && \
|
set -euo pipefail
|
||||||
TMPDIR=\$(mktemp -d) && \
|
cat > /tmp/client.tar.gz
|
||||||
cd \$TMPDIR && tar xzf /tmp/client.tar.gz && \
|
STAGE=$(mktemp -d /opt/client-app.new.XXXXXX)
|
||||||
docker cp \$TMPDIR/. \$CONTAINER:$DEST/ && \
|
tar xzf /tmp/client.tar.gz -C "$STAGE"
|
||||||
docker exec -u root \$CONTAINER chown -R frappe:frappe $DEST && \
|
# Preserve docker-compose.yml + nginx.conf from live dir (they live alongside the SPA)
|
||||||
rm -rf \$TMPDIR /tmp/client.tar.gz"
|
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
|
rm -f /tmp/client-pwa.tar.gz
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done! Client Portal: https://client.gigafibre.ca/"
|
echo "Done! Customer Portal: https://portal.gigafibre.ca/"
|
||||||
fi
|
echo "Legacy alias (302): https://client.gigafibre.ca/"
|
||||||
|
|
|
||||||
14
apps/ops/package-lock.json
generated
14
apps/ops/package-lock.json
generated
|
|
@ -12,6 +12,8 @@
|
||||||
"@twilio/voice-sdk": "^2.18.1",
|
"@twilio/voice-sdk": "^2.18.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.33.2",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"quasar": "^2.16.10",
|
"quasar": "^2.16.10",
|
||||||
|
|
@ -5892,6 +5894,12 @@
|
||||||
"node": "^14.13.1 || >=16.0.0"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|
@ -5933,6 +5941,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
# Background (2026-04-22):
|
||||||
# Customers authenticate via a passwordless magic link (email/SMS) — the
|
# The customer portal Vue SPA now lives at portal.gigafibre.ca (served by a
|
||||||
# Vue SPA's /#/login page posts to the hub, which mints a 24h JWT and
|
# standalone nginx:alpine container from /opt/client-app/; Traefik label
|
||||||
# sends it back through a link the customer clicks.
|
# 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/
|
# scp traefik-client-portal.yml root@96.125.196.67:/opt/traefik/dynamic/
|
||||||
# (Traefik auto-reloads dynamic config — no restart needed)
|
# (Traefik auto-reloads dynamic config — no restart needed)
|
||||||
#
|
#
|
||||||
|
|
@ -16,81 +25,28 @@
|
||||||
|
|
||||||
http:
|
http:
|
||||||
routers:
|
routers:
|
||||||
# Main portal router — NO authentik middleware
|
# Catch-all: every request on client.gigafibre.ca → portal.gigafibre.ca
|
||||||
client-portal:
|
# (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`)"
|
rule: "Host(`client.gigafibre.ca`)"
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- web
|
- web
|
||||||
- websecure
|
- websecure
|
||||||
service: client-portal-svc
|
service: noop@internal
|
||||||
|
middlewares:
|
||||||
|
- portal-legacy-redirect
|
||||||
tls:
|
tls:
|
||||||
certResolver: letsencrypt
|
certResolver: letsencrypt
|
||||||
# Explicitly NO middlewares — customer auth is magic-link only
|
priority: 100
|
||||||
|
|
||||||
# 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
|
|
||||||
middlewares:
|
|
||||||
- portal-redirect-magic-login
|
|
||||||
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
|
|
||||||
|
|
||||||
middlewares:
|
middlewares:
|
||||||
# Redirect /login + /update-password → SPA's magic-link request page.
|
# 302 (not 301) during the test phase so browsers don't cache the
|
||||||
# The SPA lives at /assets/client-app/ (see apps/client/deploy.sh); its
|
# redirect permanently — we can still iterate on hostnames if needed.
|
||||||
# hash router serves /#/login.
|
# Flip `permanent: true` once the portal host is stable.
|
||||||
portal-redirect-magic-login:
|
portal-legacy-redirect:
|
||||||
redirectRegex:
|
redirectRegex:
|
||||||
regex: ".*"
|
regex: "^https?://client\\.gigafibre\\.ca/(.*)"
|
||||||
replacement: "https://client.gigafibre.ca/#/login"
|
replacement: "https://portal.gigafibre.ca/$1"
|
||||||
permanent: false
|
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"
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user