fix(ops): session expirée → overlay clair + ré-auth, plus de menu blanc silencieux

Quand la session Authentik expire, nginx /auth/whoami renvoie 200 avec un
courriel VIDE (pas un 401) → ni _reauth ni le keep-alive ne le voyaient →
getLoggedUser retombait sur le propriétaire du jeton ERP, loadPermissions
était sauté, can() = false partout → menu/recherche/avis vides EN SILENCE
(l'app avait l'air cassée alors que le backend était sain).

- api/auth.js : verifySessionAlive() lit whoami (rafraîchit aussi la fenêtre
  Authentik) ; 200 + courriel vide confirmé par une 2e lecture (filtre les
  blips) = session morte → 1 rechargement auto (Traefik → login Authentik),
  sinon état sessionExpired (anti-boucle 30 s). getLoggedUser renvoie '' pour
  une session morte au lieu de masquer avec « Administrator ».
- stores/auth.js : checkSession relance verifySessionAlive si courriel vide.
- useSessionKeepAlive : le battement passe par verifySessionAlive (détecte
  enfin la mort par courriel-vide, pas seulement les 401).
- App.vue : overlay « Session expirée → Se reconnecter » au lieu d'une coquille
  vide. Inerte en dev local (localhost court-circuité).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 12:28:09 -04:00
parent 8d80b44a79
commit 96da97bfad
4 changed files with 67 additions and 5 deletions

View File

@ -1,17 +1,35 @@
<template> <template>
<router-view /> <router-view />
<!-- Session Authentik expirée : message CLAIR + reconnexion, au lieu d'une coquille vide (menu blanc, données absentes). -->
<div v-if="sessionExpired" class="ops-session-expired">
<div class="ops-session-card">
<q-icon name="lock_clock" size="44px" color="primary" />
<div class="text-h6 q-mt-sm">Session expirée</div>
<div class="text-body2 text-grey-7 q-mt-xs" style="max-width:320px">
Ta session a expiré. Reconnecte-toi pour recharger le menu, la recherche et tes données.
</div>
<q-btn unelevated color="primary" no-caps class="q-mt-md" icon="refresh" label="Se reconnecter" @click="reconnect" />
</div>
</div>
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth' import { useAuthStore } from 'src/stores/auth'
import { sessionExpired } from 'src/api/auth'
import { loadSavedTheme } from 'src/composables/useTheme' import { loadSavedTheme } from 'src/composables/useTheme'
import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive' import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive'
loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage
const auth = useAuthStore() const auth = useAuthStore()
function reconnect () { try { sessionStorage.removeItem('ops.reauth.ts') } catch (e) {} window.location.reload() }
onMounted(() => { onMounted(() => {
auth.checkSession() auth.checkSession()
startSessionKeepAlive() // garde la session Authentik vivante plus besoin de recharger pour que les données/recherche reviennent startSessionKeepAlive() // garde la session Authentik vivante plus besoin de recharger pour que les données/recherche reviennent
}) })
</script> </script>
<style>
.ops-session-expired { position: fixed; inset: 0; z-index: 99999; display: flex; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.55); backdrop-filter: blur(2px); }
.ops-session-card { background: #fff; border-radius: 14px; padding: 28px 32px; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; align-items: center; }
</style>

View File

@ -1,9 +1,46 @@
import { ref } from 'vue'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
// Token is optional — in production, nginx injects it server-side. // Token is optional — in production, nginx injects it server-side.
// Only needed for local dev (VITE_ERP_TOKEN in .env). // Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || '' 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). // Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
let _lastReload = 0 let _lastReload = 0
function _reauth (status) { function _reauth (status) {
@ -62,6 +99,9 @@ export async function getLoggedUser () {
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
if (data.email && data.email !== '') return data.email 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 {} } catch {}
// Fallback: ask ERPNext (returns API token owner, usually "Administrator") // Fallback: ask ERPNext (returns API token owner, usually "Administrator")

View File

@ -2,15 +2,15 @@
// Cause : la SPA + /api + /hub sont derrière Authentik ; la session (cookie) expire après une durée d'inactivité. // 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. // 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. // 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 { verifySessionAlive } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
let _timer = null let _timer = null
let _started = false 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 () { 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. // Démarre le battement (prod uniquement — pas d'Authentik en dev local). Idempotent.

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' 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' import { usePermissions } from 'src/composables/usePermissions'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
@ -21,6 +21,10 @@ export const useAuthStore = defineStore('auth', () => {
} finally { } finally {
loading.value = false 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 } return { user, loading, checkSession, doLogout: logout }