From e6ac8b11ef0dfd24aa78b75f7a230e56246c9bcc Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 3 Jul 2026 16:49:26 -0400 Subject: [PATCH] fix(ops): fail-loud when OPS perms don't load + username fallback (supersede 96da97b session overlay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the recurring "blank menu": the hub keys OPS permissions to the exact Authentik email (louis@targo.ca). If whoami passes a different email (duplicate account e.g. louism@targointernet.com) or an empty email, the hub 404s → can()=false → menu/search/avis silently blank, mistaken for a token bug. The ERP token and Authentik provider config were verified correct throughout. - REVERT the misleading session-fix (96da97b): drop verifySessionAlive / sessionExpired / auto-reload churn and the "Session expirée" overlay; restore the original keep-alive ping. (Re-login couldn't fix a perms/identity issue, so that overlay only added noise.) - FAIL-LOUD (App.vue): when permissions don't load in prod, show a clear overlay naming the exact account OPS received ("OPS a reçu , pas de permissions") + Changer de compte / Recharger — instead of a silent blank shell. - USERNAME FALLBACK: whoami username is captured (api/auth getAuthUsername), passed to loadPermissions, and the hub (/auth/permissions) retries by username when the email matches no provisioned OPS account. Verified on prod: username=Louis alone resolves; bad-email+username falls back; unknown → 404. Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/App.vue | 50 +++++++++++++------ apps/ops/src/api/auth.js | 43 +++------------- apps/ops/src/composables/usePermissions.js | 11 ++-- .../src/composables/useSessionKeepAlive.js | 8 +-- apps/ops/src/stores/auth.js | 14 ++---- services/targo-hub/lib/auth.js | 16 +++++- 6 files changed, 74 insertions(+), 68 deletions(-) diff --git a/apps/ops/src/App.vue b/apps/ops/src/App.vue index 916b25c..18b088e 100644 --- a/apps/ops/src/App.vue +++ b/apps/ops/src/App.vue @@ -1,35 +1,57 @@ diff --git a/apps/ops/src/api/auth.js b/apps/ops/src/api/auth.js index 4892bc7..ad27e7a 100644 --- a/apps/ops/src/api/auth.js +++ b/apps/ops/src/api/auth.js @@ -1,4 +1,3 @@ -import { ref } from 'vue' import { BASE_URL } from 'src/config/erpnext' // Token is optional — in production, nginx injects it server-side. @@ -7,39 +6,10 @@ const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || '' const isLocalHost = () => window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' -// État PARTAGÉ « session Authentik expirée » → l'app affiche un overlay clair (App.vue) au lieu d'une coquille vide (menu blanc). -export const sessionExpired = ref(false) -const REAUTH_TS = 'ops.reauth.ts' - -// Vérifie que la session Authentik est VIVANTE. whoami (servi par nginx) renvoie 200 avec un courriel VIDE quand la session -// est morte (ce n'est PAS un 401 → ni _reauth ni le keep-alive ne le voyaient → menu/recherche/avis vides en silence). -// Ce fetch rafraîchit aussi la fenêtre glissante Authentik. Session morte → 1 rechargement auto (Traefik redirige vers le -// login), puis overlay si ça persiste (garde anti-boucle 30 s). Renvoie false si la session est morte. -async function readWhoamiEmail () { // '' = 200 sans courriel · null = injoignable/non-200 (transitoire) - try { - const res = await fetch(BASE_URL + '/auth/whoami') - if (!res.ok) return null - const data = await res.json().catch(() => ({})) - return data.email || '' - } catch { return null } -} -export async function verifySessionAlive () { - if (isLocalHost()) return true // pas d'Authentik en dev local - let email = await readWhoamiEmail() - if (email === null) return true // réseau/non-200 = blip transitoire → ne PAS déconnecter - if (!email) { // courriel vide → 2e lecture pour filtrer un blip momentané (en-tête Authentik absent l'espace d'un instant) - await new Promise(r => setTimeout(r, 1200)) - const retry = await readWhoamiEmail() - if (retry === null) return true - email = retry - } - if (email) { sessionExpired.value = false; try { sessionStorage.removeItem(REAUTH_TS) } catch (e) {} return true } - // 2× 200 + courriel vide = session Authentik réellement expirée - const last = Number(sessionStorage.getItem(REAUTH_TS) || 0); const now = Date.now() - if (now - last > 30000) { try { sessionStorage.setItem(REAUTH_TS, String(now)) } catch (e) {} window.location.reload(); return false } - sessionExpired.value = true // déjà tenté récemment → overlay clair (pas de boucle de rechargement) - return false -} +// Nom d'utilisateur Authentik renvoyé par le dernier whoami — repli de recherche de permissions côté hub +// quand le courriel ne correspond à aucun compte OPS provisionné (voir stores/auth.js → loadPermissions). +let _authUsername = '' +export function getAuthUsername () { return _authUsername } // Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max). let _lastReload = 0 @@ -98,9 +68,10 @@ export async function getLoggedUser () { const res = await fetch(BASE_URL + '/auth/whoami') if (res.ok) { const data = await res.json() + _authUsername = data.username || '' // mémorisé pour le repli par username (recherche de permissions) if (data.email && data.email !== '') return data.email - // whoami joignable mais courriel VIDE en prod = session Authentik morte → renvoyer '' (signal de session expirée), - // PAS de repli sur le propriétaire du jeton ERP (« Administrator ») qui masquerait la panne derrière un menu vide. + // whoami joignable mais courriel VIDE en prod = identité Authentik non transmise → renvoyer '' (pas de repli + // trompeur sur le propriétaire du jeton ERP « Administrator »). App.vue affiche alors un message clair « fail-loud ». if (!isLocalHost()) return '' } } catch {} diff --git a/apps/ops/src/composables/usePermissions.js b/apps/ops/src/composables/usePermissions.js index 7667e5d..847f49a 100644 --- a/apps/ops/src/composables/usePermissions.js +++ b/apps/ops/src/composables/usePermissions.js @@ -16,15 +16,20 @@ let fetchPromise = null // Prevent duplicate fetches * Fetch effective permissions for the current user from targo-hub. * Reads X-Authentik-Email header (injected by Traefik). * @param {string} email — user email (from Authentik header or session) + * @param {string} [username] — Authentik username, used as a FALLBACK lookup when the email + * matches no provisioned OPS account (empty/wrong email → the hub retries by username). */ -async function loadPermissions (email) { - if (!email) return +async function loadPermissions (email, username) { + if (!email && !username) return if (fetchPromise) return fetchPromise loading.value = true error.value = null - fetchPromise = fetch(`${HUB_URL}/auth/permissions?email=${encodeURIComponent(email)}`) + const qs = new URLSearchParams() + if (email) qs.set('email', email) + if (username) qs.set('username', username) + fetchPromise = fetch(`${HUB_URL}/auth/permissions?${qs.toString()}`) .then(r => { if (!r.ok) throw new Error('Permission fetch failed: ' + r.status) return r.json() diff --git a/apps/ops/src/composables/useSessionKeepAlive.js b/apps/ops/src/composables/useSessionKeepAlive.js index 2f3315f..2d7f252 100644 --- a/apps/ops/src/composables/useSessionKeepAlive.js +++ b/apps/ops/src/composables/useSessionKeepAlive.js @@ -2,15 +2,15 @@ // Cause : la SPA + /api + /hub sont derrière Authentik ; la session (cookie) expire après une durée d'inactivité. // Les appels /api (authFetch) se rechargent seuls (_reauth), MAIS les appels /hub (fetch nu) échouent en silence → données absentes. // Solution : un battement régulier qui (a) RAFRAÎCHIT la session (fenêtre glissante) et (b) recharge proprement si elle est morte. -import { verifySessionAlive } from 'src/api/auth' +import { authFetch } from 'src/api/auth' +import { BASE_URL } from 'src/config/erpnext' let _timer = null let _started = false -// Battement à travers la porte Authentik : verifySessionAlive rafraîchit la fenêtre glissante ET détecte une session MORTE -// (whoami 200 + courriel vide, que _reauth ne voyait pas car ce n'est pas un 401) → rechargement propre / overlay clair. +// Ping léger à travers la porte Authentik. authFetch gère _reauth (recharge 1×/20 s si 401/redirection IdP/HTML). async function ping () { - try { await verifySessionAlive() } catch (e) { /* réseau transitoire → on réessaie au prochain tick */ } + try { await authFetch(BASE_URL + '/auth/whoami') } catch (e) { /* réseau transitoire → on réessaie au prochain tick */ } } // Démarre le battement (prod uniquement — pas d'Authentik en dev local). Idempotent. diff --git a/apps/ops/src/stores/auth.js b/apps/ops/src/stores/auth.js index e15931c..5e15436 100644 --- a/apps/ops/src/stores/auth.js +++ b/apps/ops/src/stores/auth.js @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { getLoggedUser, logout, verifySessionAlive } from 'src/api/auth' +import { getLoggedUser, logout, getAuthUsername } from 'src/api/auth' import { usePermissions } from 'src/composables/usePermissions' export const useAuthStore = defineStore('auth', () => { @@ -12,19 +12,15 @@ export const useAuthStore = defineStore('auth', () => { loading.value = true try { user.value = await getLoggedUser() - // Load permissions from targo-hub using the user's email - if (user.value && user.value !== 'authenticated') { - await loadPermissions(user.value) - } + // Permissions depuis le hub : par courriel, avec REPLI par username Authentik (whoami sans courriel / + // courriel non provisionné). Si les deux sont vides → permissions non chargées → App.vue affiche « fail-loud ». + const email = (user.value && user.value !== 'authenticated') ? user.value : '' + await loadPermissions(email, getAuthUsername()) } catch { user.value = 'authenticated' } finally { loading.value = false } - // getLoggedUser renvoie '' quand whoami est joignable mais sans courriel = session Authentik expirée (prod). - // Sans permissions, l'app afficherait une coquille vide (menu blanc). On relance proprement : rechargement 1× → - // login Authentik, sinon overlay clair « session expirée » (verifySessionAlive, anti-boucle). - if (!user.value) await verifySessionAlive() } return { user, loading, checkSession, doLogout: logout } diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js index 0d68c31..e7bae76 100644 --- a/services/targo-hub/lib/auth.js +++ b/services/targo-hub/lib/auth.js @@ -59,6 +59,15 @@ async function findUserByEmail (email) { return user ? { user } : { error: 'User not found: ' + email, status: 404 } } +// Repli : quand le courriel ne correspond à aucun compte OPS (courriel vide côté whoami, ou différent de +// celui provisionné), on retrouve l'utilisateur par son username Authentik (transmis aussi par whoami). +async function findUserByUsername (username) { + const r = await akFetch('/core/users/?search=' + encodeURIComponent(username) + '&page_size=5') + if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 } + const user = r.data.results?.find(u => u.username?.toLowerCase() === username.toLowerCase()) + return user ? { user } : { error: 'User not found: ' + username, status: 404 } +} + function validatePermissions (body) { const validKeys = new Set(CAPABILITIES.map(c => c.key)) const result = {} @@ -81,9 +90,12 @@ async function handle (req, res, method, path, url) { if (resource === 'permissions' && method === 'GET') { const email = url.searchParams.get('email') || req.headers['x-authentik-email'] - if (!email) return json(res, 400, { error: 'Missing email parameter' }) + const username = url.searchParams.get('username') || req.headers['x-authentik-username'] + if (!email && !username) return json(res, 400, { error: 'Missing email or username parameter' }) - const lookup = await findUserByEmail(email) + // Recherche par courriel, puis REPLI par username (courriel absent/non provisionné). + let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 } + if (lookup.error && username) lookup = await findUserByUsername(username) if (lookup.error) return json(res, lookup.status, { error: lookup.error }) const { user } = lookup