fix(ops): fail-loud when OPS perms don't load + username fallback (supersede 96da97b session overlay)
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 <email>, 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 <noreply@anthropic.com>
This commit is contained in:
parent
96da97bfad
commit
e6ac8b11ef
|
|
@ -1,35 +1,57 @@
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
<!-- Session Authentik expirée : message CLAIR + reconnexion, au lieu d'une coquille vide (menu blanc, données absentes). -->
|
<!-- « FAIL-LOUD » : les permissions OPS n'ont pas chargé (mauvais compte / identité Authentik non transmise).
|
||||||
<div v-if="sessionExpired" class="ops-session-expired">
|
On affiche un message CLAIR avec le compte réellement reçu, au lieu d'une coquille vide (menu blanc) qu'on
|
||||||
<div class="ops-session-card">
|
prenait à tort pour un bug de token/session. Inerte en dev local (profil permissif). -->
|
||||||
<q-icon name="lock_clock" size="44px" color="primary" />
|
<div v-if="showAccessError" class="ops-access-error">
|
||||||
<div class="text-h6 q-mt-sm">Session expirée</div>
|
<div class="ops-access-card">
|
||||||
<div class="text-body2 text-grey-7 q-mt-xs" style="max-width:320px">
|
<q-icon name="person_off" size="44px" color="negative" />
|
||||||
Ta session a expiré. Reconnecte-toi pour recharger le menu, la recherche et tes données.
|
<div class="text-h6 q-mt-sm">Accès OPS indisponible</div>
|
||||||
|
<div class="text-body2 text-grey-7 q-mt-sm" style="max-width:380px;line-height:1.5">
|
||||||
|
<template v-if="authIdentity">
|
||||||
|
OPS a reçu le compte <b>{{ authIdentity }}</b>, mais il n'a <b>pas de permissions OPS</b>.
|
||||||
|
Es-tu connecté avec ton <b>compte principal</b> ? (Un seul compte par personne a les accès OPS —
|
||||||
|
évite les comptes en double.)
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Authentik n'a pas transmis ton identité (session incomplète). Reconnecte-toi via
|
||||||
|
<b>erp.gigafibre.ca/ops</b>.
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="row q-gutter-sm q-mt-md justify-center">
|
||||||
|
<q-btn outline no-caps color="grey-8" icon="logout" label="Changer de compte" @click="auth.doLogout()" />
|
||||||
|
<q-btn unelevated no-caps color="primary" icon="refresh" label="Recharger" @click="reload" />
|
||||||
</div>
|
</div>
|
||||||
<q-btn unelevated color="primary" no-caps class="q-mt-md" icon="refresh" label="Se reconnecter" @click="reconnect" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useAuthStore } from 'src/stores/auth'
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
import { sessionExpired } from 'src/api/auth'
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
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() }
|
const { isLoaded, loading: permsLoading } = usePermissions()
|
||||||
|
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||||
|
|
||||||
|
// PROD uniquement : une fois le contrôle de session ET le chargement des permissions terminés, si aucune
|
||||||
|
// permission n'a chargé → accès indisponible (mauvais compte / identité vide) → overlay clair.
|
||||||
|
const showAccessError = computed(() => !isLocal && !auth.loading && !permsLoading.value && !isLoaded.value)
|
||||||
|
// Le compte réellement transmis à OPS (courriel whoami) — clé du diagnostic : « mauvais compte ? ».
|
||||||
|
const authIdentity = computed(() => (auth.user && auth.user !== 'authenticated') ? auth.user : '')
|
||||||
|
function reload () { 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 (ping /auth/whoami /4 min + au retour d'onglet)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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-access-error { 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; }
|
.ops-access-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; max-width: 92vw; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
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.
|
||||||
|
|
@ -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'
|
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).
|
// Nom d'utilisateur Authentik renvoyé par le dernier whoami — repli de recherche de permissions côté hub
|
||||||
export const sessionExpired = ref(false)
|
// quand le courriel ne correspond à aucun compte OPS provisionné (voir stores/auth.js → loadPermissions).
|
||||||
const REAUTH_TS = 'ops.reauth.ts'
|
let _authUsername = ''
|
||||||
|
export function getAuthUsername () { return _authUsername }
|
||||||
// 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
|
||||||
|
|
@ -98,9 +68,10 @@ export async function getLoggedUser () {
|
||||||
const res = await fetch(BASE_URL + '/auth/whoami')
|
const res = await fetch(BASE_URL + '/auth/whoami')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
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
|
if (data.email && data.email !== '') return data.email
|
||||||
// whoami joignable mais courriel VIDE en prod = session Authentik morte → renvoyer '' (signal de session expirée),
|
// whoami joignable mais courriel VIDE en prod = identité Authentik non transmise → renvoyer '' (pas de repli
|
||||||
// PAS de repli sur le propriétaire du jeton ERP (« Administrator ») qui masquerait la panne derrière un menu vide.
|
// trompeur sur le propriétaire du jeton ERP « Administrator »). App.vue affiche alors un message clair « fail-loud ».
|
||||||
if (!isLocalHost()) return ''
|
if (!isLocalHost()) return ''
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,20 @@ let fetchPromise = null // Prevent duplicate fetches
|
||||||
* Fetch effective permissions for the current user from targo-hub.
|
* Fetch effective permissions for the current user from targo-hub.
|
||||||
* Reads X-Authentik-Email header (injected by Traefik).
|
* Reads X-Authentik-Email header (injected by Traefik).
|
||||||
* @param {string} email — user email (from Authentik header or session)
|
* @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) {
|
async function loadPermissions (email, username) {
|
||||||
if (!email) return
|
if (!email && !username) return
|
||||||
if (fetchPromise) return fetchPromise
|
if (fetchPromise) return fetchPromise
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
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 => {
|
.then(r => {
|
||||||
if (!r.ok) throw new Error('Permission fetch failed: ' + r.status)
|
if (!r.ok) throw new Error('Permission fetch failed: ' + r.status)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
|
||||||
|
|
@ -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 { verifySessionAlive } from 'src/api/auth'
|
import { authFetch } from 'src/api/auth'
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
let _timer = null
|
let _timer = null
|
||||||
let _started = false
|
let _started = false
|
||||||
|
|
||||||
// Battement à travers la porte Authentik : verifySessionAlive rafraîchit la fenêtre glissante ET détecte une session MORTE
|
// Ping léger à travers la porte Authentik. authFetch gère _reauth (recharge 1×/20 s si 401/redirection IdP/HTML).
|
||||||
// (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 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.
|
// Démarre le battement (prod uniquement — pas d'Authentik en dev local). Idempotent.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
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'
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
@ -12,19 +12,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
user.value = await getLoggedUser()
|
user.value = await getLoggedUser()
|
||||||
// Load permissions from targo-hub using the user's email
|
// Permissions depuis le hub : par courriel, avec REPLI par username Authentik (whoami sans courriel /
|
||||||
if (user.value && user.value !== 'authenticated') {
|
// courriel non provisionné). Si les deux sont vides → permissions non chargées → App.vue affiche « fail-loud ».
|
||||||
await loadPermissions(user.value)
|
const email = (user.value && user.value !== 'authenticated') ? user.value : ''
|
||||||
}
|
await loadPermissions(email, getAuthUsername())
|
||||||
} catch {
|
} catch {
|
||||||
user.value = 'authenticated'
|
user.value = 'authenticated'
|
||||||
} 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 }
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,15 @@ async function findUserByEmail (email) {
|
||||||
return user ? { user } : { error: 'User not found: ' + email, status: 404 }
|
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) {
|
function validatePermissions (body) {
|
||||||
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
||||||
const result = {}
|
const result = {}
|
||||||
|
|
@ -81,9 +90,12 @@ async function handle (req, res, method, path, url) {
|
||||||
|
|
||||||
if (resource === 'permissions' && method === 'GET') {
|
if (resource === 'permissions' && method === 'GET') {
|
||||||
const email = url.searchParams.get('email') || req.headers['x-authentik-email']
|
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 })
|
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
|
||||||
const { user } = lookup
|
const { user } = lookup
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user