fix: multi-sessions + système de toasts pour erreurs visibles

Sessions:
- Cookie pxp_refresh path: /api/auth/refresh → /api/auth/ pour être envoyé au logout
- Logout supprime uniquement le token de la session courante (via cookie hash)
  si pas de cookie = fallback suppression globale (rétro-compat)

Toasts:
- Store Alpine.store('toasts') avec error/success/warn/info + auto-dismiss
- Conteneur #pxp-toasts injecté dans <body>, persiste à travers les navigations Swup
- fetchMe(): HTTP non-401 → toast explicite (ex: HTTP 404 backend down)
- tryRefresh(): session expirée → sessionStorage → toast orange sur la page login
- logout(): message "Déconnexion réussie" sur la page login
- proxmoxPage.action(): toast succès/erreur pour start/stop LXC
- profilePage.revokeSession(): toast confirmation révocation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-22 01:04:37 +01:00
parent 21e1e0ed1e
commit dc0c67b89c
3 changed files with 151 additions and 11 deletions

View file

@ -97,6 +97,47 @@ function apiFetch(path, opts = {}) {
document.addEventListener('alpine:init', () => {
// ── Store toasts ────────────────────────────────────────────────────────
Alpine.store('toasts', {
items: [],
_id: 0,
add(message, type = 'info', duration = 5000) {
const id = ++this._id
this.items.push({ id, message, type })
if (duration > 0) setTimeout(() => this.remove(id), duration)
return id
},
remove(id) { this.items = this.items.filter(t => t.id !== id) },
error(msg) { return this.add(msg, 'error', 8000) },
success(msg) { return this.add(msg, 'success', 4000) },
warn(msg) { return this.add(msg, 'warn', 6000) },
info(msg) { return this.add(msg, 'info', 4000) },
})
// Injecter le conteneur de toasts dans le body (persiste à travers Swup)
;(function() {
const tc = document.createElement('div')
tc.id = 'pxp-toasts'
tc.setAttribute('x-data', '')
tc.innerHTML = `
<template x-for="t in $store.toasts.items" :key="t.id">
<div class="toast" :class="'toast--' + t.type" x-transition.opacity>
<i :class="{
'lnid-cross-circle': t.type === 'error',
'lnid-check-circle-1': t.type === 'success',
'lnid-warning-triangle-1':t.type === 'warn',
'lnid-information-circle':t.type === 'info'
}"></i>
<span class="toast-msg" x-text="t.message"></span>
<button class="toast-close" @click="$store.toasts.remove(t.id)">
<i class="lnid-cross"></i>
</button>
</div>
</template>
`
document.body.appendChild(tc)
})()
// ── Store auth ─────────────────────────────────────────────────────────
Alpine.store('auth', {
token: null,
@ -131,8 +172,15 @@ document.addEventListener('alpine:init', () => {
if (u.lang && u.lang !== Alpine.store('i18n').lang) {
Alpine.store('i18n').setLang(u.lang)
}
} else if (res.status === 401) {
// Token expiré → tenter un refresh silencieusement
await this.tryRefresh()
} else {
// Token expiré, invalide, ou toute autre erreur → tenter un refresh
// Erreur inattendue (404, 500…) — signaler + tenter quand même
console.error(`[auth/me] HTTP ${res.status}`)
Alpine.store('toasts').error(
`Erreur serveur (${res.status}) sur /api/auth/me — le backend est-il démarré ?`
)
await this.tryRefresh()
}
},
@ -141,11 +189,19 @@ document.addEventListener('alpine:init', () => {
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
// Le backend retourne "access_token" (pas "token")
this.token = data.access_token
localStorage.setItem('pxp_token', data.access_token)
await this.fetchMe()
} else {
// Session expirée ou révoquée → notifier via sessionStorage (visible sur la page login)
const page = window.location.pathname.replace(/^\/|\.html$/g, '')
if (page !== 'login' && page !== 'install' && page !== 'index' && page !== '') {
sessionStorage.setItem('pxp_auth_notice',
res.status === 401
? 'Session expirée ou révoquée — veuillez vous reconnecter'
: `Erreur ${res.status} lors du renouvellement de session`
)
}
this.clear()
}
},
@ -171,6 +227,7 @@ document.addEventListener('alpine:init', () => {
async logout() {
await apiFetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
this.clear()
sessionStorage.setItem('pxp_auth_notice', 'Déconnexion réussie')
window.location.href = '/login.html'
},
@ -315,6 +372,15 @@ document.addEventListener('alpine:init', () => {
error: '',
loading: false,
init() {
// Afficher le message si la session a expiré avant la redirection
const msg = sessionStorage.getItem('pxp_auth_notice')
if (msg) {
sessionStorage.removeItem('pxp_auth_notice')
setTimeout(() => Alpine.store('toasts').warn(msg), 200)
}
},
async submit() {
this.error = ''
this.loading = true
@ -607,8 +673,16 @@ document.addEventListener('alpine:init', () => {
this.revoking = { ...this.revoking, [id]: true }
try {
const res = await apiFetch(`/api/auth/sessions/${id}`, { method: 'DELETE' })
if (res.ok) this.sessions = this.sessions.filter(s => s.id !== id)
} catch (e) { /* ignore */ }
if (res.ok) {
this.sessions = this.sessions.filter(s => s.id !== id)
Alpine.store('toasts').success('Session révoquée')
} else {
const d = await res.json().catch(() => ({}))
Alpine.store('toasts').error(d.error || 'Erreur révocation session')
}
} catch (e) {
Alpine.store('toasts').error(`Erreur réseau — ${e.message}`)
}
this.revoking = { ...this.revoking, [id]: false }
},
@ -673,9 +747,15 @@ document.addEventListener('alpine:init', () => {
const key = `${vmid}-${action}`
this.actionLoading[key] = true
try {
await apiFetch(`/api/proxmox/${type}/${vmid}/${action}`, { method: 'POST' })
const res = await apiFetch(`/api/proxmox/${type}/${vmid}/${action}`, { method: 'POST' })
if (res.ok) {
Alpine.store('toasts').success(`Action « ${action} » envoyée sur ${type} ${vmid}`)
} else {
const d = await res.json().catch(() => ({}))
Alpine.store('toasts').error(d.error || `Erreur action ${action} (HTTP ${res.status})`)
}
} catch(e) {
console.error(e)
Alpine.store('toasts').error(`Erreur réseau — ${e.message}`)
} finally {
this.actionLoading[key] = false
}