diff --git a/.gitignore b/.gitignore index 8467616..d9fc5f5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ apps/**/.env apps/**/.env.local +# Legacy migration exports — customer PII, never commit (purged from history 2026-06-16) +scripts/migration/genieacs-export/*.tsv +scripts/migration/genieacs-export/devices-*.json + # Dependencies node_modules/ diff --git a/apps/client/deploy.sh b/apps/client/deploy.sh index 185b178..b77bc67 100755 --- a/apps/client/deploy.sh +++ b/apps/client/deploy.sh @@ -30,9 +30,9 @@ echo "==> Installing dependencies..." npm ci --silent 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 +# Do NOT bake an ERPNext token into the portal bundle. Any direct ERPNext +# calls (catalog, invoice PDFs) must go through a server-side token proxy. +DEPLOY_BASE=/ npx quasar build -m pwa if [ "${1:-}" = "local" ]; then echo "" diff --git a/apps/client/src/api/payments.js b/apps/client/src/api/payments.js index 3d8356b..fa4a7b5 100644 --- a/apps/client/src/api/payments.js +++ b/apps/client/src/api/payments.js @@ -3,8 +3,17 @@ */ const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' +// Attach the customer's magic-link JWT (stored by the customer store) so the hub can +// authorize per-customer access to balance/methods/invoice/portal/toggle-ppa — see +// stores/customer.js TOKEN_KEY (kept in sync). +function authHeaders (base = {}) { + let t = '' + try { t = sessionStorage.getItem('targo_portal_jwt') || '' } catch { t = '' } + return t ? { ...base, Authorization: 'Bearer ' + t } : base +} + async function hubGet (path) { - const r = await fetch(HUB + path) + const r = await fetch(HUB + path, { headers: authHeaders() }) if (!r.ok) { const data = await r.json().catch(() => ({})) throw new Error(data.error || `Hub ${r.status}`) @@ -15,7 +24,7 @@ async function hubGet (path) { async function hubPost (path, body) { const r = await fetch(HUB + path, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(body), }) const data = await r.json().catch(() => ({})) diff --git a/apps/client/src/stores/customer.js b/apps/client/src/stores/customer.js index 2ef3750..01d98a7 100644 --- a/apps/client/src/stores/customer.js +++ b/apps/client/src/stores/customer.js @@ -2,6 +2,10 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { getPortalUser } from 'src/api/portal' +// sessionStorage key holding the raw magic-link JWT for the current tab session. +// Read by src/api/payments.js to authorize /payments/* calls — keep the two in sync. +const TOKEN_KEY = 'targo_portal_jwt' + /** * Customer session store. * @@ -60,17 +64,20 @@ export const useCustomerStore = defineStore('customer', () => { const email = ref('') const customerId = ref('') const customerName = ref('') + const token = ref('') const loading = ref(true) const error = ref(null) function hydrateFromToken () { - const token = readTokenFromLocation() - if (!token) return false - const payload = decodeJwtPayload(token) + const t = readTokenFromLocation() + if (!t) return false + const payload = decodeJwtPayload(t) if (!payload || payload.scope !== 'customer') return false customerId.value = payload.sub customerName.value = payload.name || payload.sub email.value = payload.email || '' + token.value = t + try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* private mode */ } stripTokenFromUrl() return true } @@ -82,6 +89,24 @@ export const useCustomerStore = defineStore('customer', () => { // Path 1: magic-link token in URL if (hydrateFromToken()) return + // Restore a prior magic-link session (sessionStorage) so a page reload keeps + // both the identity AND the raw token we send on /payments/* calls. + if (!customerId.value) { + let saved = '' + try { saved = sessionStorage.getItem(TOKEN_KEY) || '' } catch { saved = '' } + if (saved) { + const payload = decodeJwtPayload(saved) + if (payload && payload.scope === 'customer') { + token.value = saved + customerId.value = payload.sub + customerName.value = payload.name || payload.sub + email.value = payload.email || '' + return + } + try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* expired/invalid */ } + } + } + // Already authenticated in this tab? (e.g. subsequent nav) if (customerId.value) return @@ -102,8 +127,10 @@ export const useCustomerStore = defineStore('customer', () => { email.value = '' customerId.value = '' customerName.value = '' + token.value = '' error.value = null + try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* ignore */ } } - return { email, customerId, customerName, loading, error, init, hydrateFromToken, clear } + return { email, customerId, customerName, token, loading, error, init, hydrateFromToken, clear } }) diff --git a/apps/ops/.env.production b/apps/ops/.env.production new file mode 100644 index 0000000..76361e9 --- /dev/null +++ b/apps/ops/.env.production @@ -0,0 +1,6 @@ +# Production builds MUST NOT bake the ERPNext token into the JS bundle. +# In prod the ops-frontend nginx injects `Authorization: token …` server-side +# (see apps/ops/infra/nginx.conf). The client calls same-origin /ops/api and never +# holds a token. Leaving this empty keeps the admin key out of the shipped bundle. +# Local dev still uses apps/ops/.env (which may set VITE_ERP_TOKEN for `quasar dev`). +VITE_ERP_TOKEN= diff --git a/apps/ops/infra/nginx.conf b/apps/ops/infra/nginx.conf index 75258fd..c8ae2da 100644 --- a/apps/ops/infra/nginx.conf +++ b/apps/ops/infra/nginx.conf @@ -12,7 +12,7 @@ server { proxy_pass https://erp.gigafibre.ca; proxy_ssl_verify off; proxy_set_header Host erp.gigafibre.ca; - proxy_set_header Authorization "token b273a666c86d2d0:06120709db5e414"; + proxy_set_header Authorization "token __ERP_API_TOKEN__"; # injected at deploy — never commit a real token proxy_set_header X-Authentik-Email $http_x_authentik_email; proxy_set_header X-Authentik-Username $http_x_authentik_username; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index eda10c3..aefb323 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -69,6 +69,14 @@ export function sendCampaign (id) { }) } +// Duplicate a campaign as a fresh draft (id "_2", recipients reset to pending). +// Returns the new draft campaign so the caller can open it for review/send. +export function cloneCampaign (id) { + return hubFetch(`/campaigns/${encodeURIComponent(id)}/clone`, { + method: 'POST', + }) +} + // Permanent deletion — removes the JSON on the hub. Used for clearing // test campaigns from the list. Giftbit shortlinks are unaffected. export function deleteCampaign (id) { diff --git a/apps/ops/src/components/shared/ConversationPanel.vue b/apps/ops/src/components/shared/ConversationPanel.vue index 5b26fda..e3c0d6c 100644 --- a/apps/ops/src/components/shared/ConversationPanel.vue +++ b/apps/ops/src/components/shared/ConversationPanel.vue @@ -1,8 +1,8 @@