fix: server-side API token injection + ticket modal empty state

- Move ERPNext API token from JS bundle to nginx proxy_set_header
  (token only lives on server, never in client code)
- Switch ops + field apps from auth.targo.ca to id.gigafibre.ca SSO
- Fix "Aucun contenu" showing on tickets that have comments but no
  description (check comments.length in v-if condition)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-30 23:31:58 -04:00
parent 11cd38f93c
commit 1ed86e37ad
9 changed files with 58 additions and 24 deletions

View File

@ -22,7 +22,7 @@ echo "==> Installing dependencies..."
npm ci --silent npm ci --silent
echo "==> Building PWA (base=/field/)..." echo "==> Building PWA (base=/field/)..."
VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/field/ npx quasar build -m pwa DEPLOY_BASE=/field/ npx quasar build -m pwa
if [ "$1" = "local" ]; then if [ "$1" = "local" ]; then
echo "==> Deploying to local $DEST..." echo "==> Deploying to local $DEST..."

View File

@ -15,7 +15,7 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.field.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/field`)" - "traefik.http.routers.field.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/field`)"
- "traefik.http.routers.field.entrypoints=web,websecure" - "traefik.http.routers.field.entrypoints=web,websecure"
- "traefik.http.routers.field.middlewares=authentik@file,field-strip@docker" - "traefik.http.routers.field.middlewares=authentik-client@file,field-strip@docker"
- "traefik.http.routers.field.service=field" - "traefik.http.routers.field.service=field"
- "traefik.http.routers.field.tls.certresolver=letsencrypt" - "traefik.http.routers.field.tls.certresolver=letsencrypt"
- "traefik.http.routers.field.priority=200" - "traefik.http.routers.field.priority=200"

View File

@ -4,6 +4,19 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# ERPNext API proxy token injected server-side (never in JS bundle)
location /api/ {
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 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;
proxy_set_header X-Forwarded-Proto https;
}
# SPA fallback
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@ -1,12 +1,18 @@
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || '' // Token is optional — in production, nginx injects it server-side.
// Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
export function authFetch (url, opts = {}) { export function authFetch (url, opts = {}) {
if (SERVICE_TOKEN) {
opts.headers = { opts.headers = {
...opts.headers, ...opts.headers,
Authorization: 'token ' + SERVICE_TOKEN, Authorization: 'token ' + SERVICE_TOKEN,
} }
} else {
opts.headers = { ...opts.headers }
}
opts.redirect = 'manual' opts.redirect = 'manual'
if (opts.method && opts.method !== 'GET') { if (opts.method && opts.method !== 'GET') {
opts.credentials = 'omit' opts.credentials = 'omit'
@ -22,9 +28,8 @@ export function authFetch (url, opts = {}) {
export async function getLoggedUser () { export async function getLoggedUser () {
try { try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { const headers = SERVICE_TOKEN ? { Authorization: 'token ' + SERVICE_TOKEN } : {}
headers: { Authorization: 'token ' + SERVICE_TOKEN }, const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { headers })
})
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
return data.message || 'authenticated' return data.message || 'authenticated'
@ -34,5 +39,5 @@ export async function getLoggedUser () {
} }
export async function logout () { export async function logout () {
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/' window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/'
} }

View File

@ -29,7 +29,7 @@ echo "==> Installing dependencies..."
npm ci --silent npm ci --silent
echo "==> Building PWA (base=/ops/)..." echo "==> Building PWA (base=/ops/)..."
VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/ops/ npx quasar build -m pwa DEPLOY_BASE=/ops/ npx quasar build -m pwa
if [ "$1" = "local" ]; then if [ "$1" = "local" ]; then
# ── Local deploy ── # ── Local deploy ──

View File

@ -24,7 +24,7 @@ services:
# Main router: erp.gigafibre.ca/ops/* with Authentik + StripPrefix # Main router: erp.gigafibre.ca/ops/* with Authentik + StripPrefix
- "traefik.http.routers.ops.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/ops`)" - "traefik.http.routers.ops.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/ops`)"
- "traefik.http.routers.ops.entrypoints=web,websecure" - "traefik.http.routers.ops.entrypoints=web,websecure"
- "traefik.http.routers.ops.middlewares=authentik@file,ops-strip@docker" - "traefik.http.routers.ops.middlewares=authentik-client@file,ops-strip@docker"
- "traefik.http.routers.ops.service=ops" - "traefik.http.routers.ops.service=ops"
- "traefik.http.routers.ops.tls.certresolver=letsencrypt" - "traefik.http.routers.ops.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops.priority=200" - "traefik.http.routers.ops.priority=200"
@ -34,7 +34,7 @@ services:
# Authentik outpost callback (required for login redirect) # Authentik outpost callback (required for login redirect)
- "traefik.http.routers.ops-ak.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/outpost.goauthentik.io/`)" - "traefik.http.routers.ops-ak.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.ops-ak.entrypoints=web,websecure" - "traefik.http.routers.ops-ak.entrypoints=web,websecure"
- "traefik.http.routers.ops-ak.middlewares=authentik@file" - "traefik.http.routers.ops-ak.middlewares=authentik-client@file"
- "traefik.http.routers.ops-ak.service=ops" - "traefik.http.routers.ops-ak.service=ops"
- "traefik.http.routers.ops-ak.tls.certresolver=letsencrypt" - "traefik.http.routers.ops-ak.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops-ak.priority=250" - "traefik.http.routers.ops-ak.priority=250"

View File

@ -4,6 +4,19 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# ERPNext API proxy token injected server-side (never in JS bundle)
# To rotate: edit this file + docker restart ops-frontend
location /api/ {
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 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;
proxy_set_header X-Forwarded-Proto https;
}
# SPA fallback all routes serve index.html # SPA fallback all routes serve index.html
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@ -1,15 +1,19 @@
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || '' // Token is optional — in production, nginx injects it server-side.
// Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
export function authFetch (url, opts = {}) { export function authFetch (url, opts = {}) {
if (SERVICE_TOKEN) {
opts.headers = { opts.headers = {
...opts.headers, ...opts.headers,
Authorization: 'token ' + SERVICE_TOKEN, Authorization: 'token ' + SERVICE_TOKEN,
} }
} else {
opts.headers = { ...opts.headers }
}
opts.redirect = 'manual' opts.redirect = 'manual'
// For state-changing requests, omit cookies to avoid CSRF check
// (token auth doesn't require CSRF, but session cookies trigger it)
if (opts.method && opts.method !== 'GET') { if (opts.method && opts.method !== 'GET') {
opts.credentials = 'omit' opts.credentials = 'omit'
} }
@ -24,9 +28,8 @@ export function authFetch (url, opts = {}) {
export async function getLoggedUser () { export async function getLoggedUser () {
try { try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { const headers = SERVICE_TOKEN ? { Authorization: 'token ' + SERVICE_TOKEN } : {}
headers: { Authorization: 'token ' + SERVICE_TOKEN }, const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', { headers })
})
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
return data.message || 'authenticated' return data.message || 'authenticated'
@ -36,5 +39,5 @@ export async function getLoggedUser () {
} }
export async function logout () { export async function logout () {
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/' window.location.href = 'https://id.gigafibre.ca/if/flow/default-invalidation-flow/'
} }

View File

@ -139,7 +139,7 @@
<div class="thread-body" v-html="c.content"></div> <div class="thread-body" v-html="c.content"></div>
</div> </div>
</div> </div>
<div v-if="!doc.description && !doc.resolution_details && !comms.length" class="text-center text-grey-5 q-pa-lg"> <div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg">
Aucun contenu pour ce ticket Aucun contenu pour ce ticket
</div> </div>
</template> </template>