diff --git a/apps/ops/src/App.vue b/apps/ops/src/App.vue index f73455e..916b25c 100644 --- a/apps/ops/src/App.vue +++ b/apps/ops/src/App.vue @@ -1,17 +1,35 @@ + + diff --git a/apps/ops/src/api/auth.js b/apps/ops/src/api/auth.js index cf43779..4892bc7 100644 --- a/apps/ops/src/api/auth.js +++ b/apps/ops/src/api/auth.js @@ -1,9 +1,46 @@ +import { ref } from 'vue' import { BASE_URL } from 'src/config/erpnext' // 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 || '' +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 +} + // Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max). let _lastReload = 0 function _reauth (status) { @@ -62,6 +99,9 @@ export async function getLoggedUser () { if (res.ok) { const data = await res.json() 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. + if (!isLocalHost()) return '' } } catch {} // Fallback: ask ERPNext (returns API token owner, usually "Administrator") diff --git a/apps/ops/src/composables/useSessionKeepAlive.js b/apps/ops/src/composables/useSessionKeepAlive.js index 2d7f252..2f3315f 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 { authFetch } from 'src/api/auth' -import { BASE_URL } from 'src/config/erpnext' +import { verifySessionAlive } from 'src/api/auth' let _timer = null let _started = false -// Ping léger à travers la porte Authentik. authFetch gère _reauth (recharge 1×/20 s si 401/redirection IdP/HTML). +// 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. async function ping () { - try { await authFetch(BASE_URL + '/auth/whoami') } catch (e) { /* réseau transitoire → on réessaie au prochain tick */ } + try { await verifySessionAlive() } 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 d394630..e15931c 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 } from 'src/api/auth' +import { getLoggedUser, logout, verifySessionAlive } from 'src/api/auth' import { usePermissions } from 'src/composables/usePermissions' export const useAuthStore = defineStore('auth', () => { @@ -21,6 +21,10 @@ export const useAuthStore = defineStore('auth', () => { } 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 }