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

@ -118,10 +118,11 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// Cookie httpOnly pour le refresh token // Cookie httpOnly pour le refresh token
// Secure=true si TLS direct ou si derrière un proxy (Traefik) qui a terminé TLS // Secure=true si TLS direct ou si derrière un proxy (Traefik) qui a terminé TLS
isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
// Path élargi à /api/auth/ pour que le cookie soit envoyé au logout aussi
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "pxp_refresh", Name: "pxp_refresh",
Value: refreshToken, Value: refreshToken,
Path: "/api/auth/refresh", Path: "/api/auth/",
HttpOnly: true, HttpOnly: true,
Secure: isHTTPS, Secure: isHTTPS,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
@ -141,14 +142,22 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}) })
} }
// Logout invalide la session de l'utilisateur. // Logout invalide la session courante de l'utilisateur.
// POST /api/auth/logout // POST /api/auth/logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r) claims := GetClaims(r)
// Supprimer tous les refresh tokens de cet utilisateur
if claims != nil { if claims != nil {
h.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, claims.UserID) // Supprimer uniquement le token de CETTE session (via cookie pxp_refresh)
// Le cookie a Path=/api/auth/ donc il est bien envoyé sur ce endpoint.
if cookie, err := r.Cookie("pxp_refresh"); err == nil {
tokenHash := hashToken(cookie.Value)
h.db.Exec(`DELETE FROM refresh_tokens WHERE token_hash = ? AND user_id = ?`,
tokenHash, claims.UserID)
} else {
// Pas de cookie (session dégradée ou ancien cookie path) → supprimer toutes les sessions
h.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, claims.UserID)
}
h.auditLogger.Log(&claims.UserID, claims.Username, "logout", "", nil, clientIP(r)) h.auditLogger.Log(&claims.UserID, claims.Username, "logout", "", nil, clientIP(r))
} }
@ -156,7 +165,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "pxp_refresh", Name: "pxp_refresh",
Value: "", Value: "",
Path: "/api/auth/refresh", Path: "/api/auth/",
HttpOnly: true, HttpOnly: true,
Expires: time.Unix(0, 0), Expires: time.Unix(0, 0),
MaxAge: -1, MaxAge: -1,

View file

@ -763,6 +763,57 @@
.history-status.success { color: var(--neu-success); } .history-status.success { color: var(--neu-success); }
.history-status.error { color: var(--neu-danger); } .history-status.error { color: var(--neu-danger); }
/* ── Toasts ──────────────────────────────────────────────────────────────────── */
#pxp-toasts {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: .5rem;
pointer-events: none;
max-width: 24rem;
}
.toast {
display: flex;
align-items: flex-start;
gap: .6rem;
padding: .75rem 1rem;
border-radius: var(--neu-radius);
background: var(--neu-surface);
box-shadow: 0 4px 16px rgba(0,0,0,.35);
border-left: 3px solid transparent;
font-size: .875rem;
pointer-events: all;
animation: toast-in .2s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(1rem); }
to { opacity: 1; transform: translateX(0); }
}
.toast > i:first-child { margin-top: .05rem; flex-shrink: 0; font-size: 1rem; }
.toast-msg { flex: 1; color: var(--neu-text); line-height: 1.4; }
.toast-close {
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--neu-text-muted);
line-height: 1;
flex-shrink: 0;
margin-top: .05rem;
}
.toast-close:hover { color: var(--neu-text); }
.toast--error { border-left-color: var(--neu-danger); }
.toast--error > i:first-child { color: var(--neu-danger); }
.toast--success { border-left-color: var(--neu-success); }
.toast--success > i:first-child { color: var(--neu-success); }
.toast--warn { border-left-color: var(--neu-warning); }
.toast--warn > i:first-child { color: var(--neu-warning); }
.toast--info { border-left-color: var(--neu-info); }
.toast--info > i:first-child { color: var(--neu-info); }
/* ── Éditeur de raccourcis ───────────────────────────────────────────────────── */ /* ── Éditeur de raccourcis ───────────────────────────────────────────────────── */
.shortcuts-editor { display: flex; flex-direction: column; gap: .5rem; } .shortcuts-editor { display: flex; flex-direction: column; gap: .5rem; }
.shortcut-row { .shortcut-row {

View file

@ -97,6 +97,47 @@ function apiFetch(path, opts = {}) {
document.addEventListener('alpine:init', () => { 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 ───────────────────────────────────────────────────────── // ── Store auth ─────────────────────────────────────────────────────────
Alpine.store('auth', { Alpine.store('auth', {
token: null, token: null,
@ -131,8 +172,15 @@ document.addEventListener('alpine:init', () => {
if (u.lang && u.lang !== Alpine.store('i18n').lang) { if (u.lang && u.lang !== Alpine.store('i18n').lang) {
Alpine.store('i18n').setLang(u.lang) Alpine.store('i18n').setLang(u.lang)
} }
} else if (res.status === 401) {
// Token expiré → tenter un refresh silencieusement
await this.tryRefresh()
} else { } 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() await this.tryRefresh()
} }
}, },
@ -141,11 +189,19 @@ document.addEventListener('alpine:init', () => {
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' }) const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
// Le backend retourne "access_token" (pas "token")
this.token = data.access_token this.token = data.access_token
localStorage.setItem('pxp_token', data.access_token) localStorage.setItem('pxp_token', data.access_token)
await this.fetchMe() await this.fetchMe()
} else { } 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() this.clear()
} }
}, },
@ -171,6 +227,7 @@ document.addEventListener('alpine:init', () => {
async logout() { async logout() {
await apiFetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) await apiFetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
this.clear() this.clear()
sessionStorage.setItem('pxp_auth_notice', 'Déconnexion réussie')
window.location.href = '/login.html' window.location.href = '/login.html'
}, },
@ -315,6 +372,15 @@ document.addEventListener('alpine:init', () => {
error: '', error: '',
loading: false, 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() { async submit() {
this.error = '' this.error = ''
this.loading = true this.loading = true
@ -607,8 +673,16 @@ document.addEventListener('alpine:init', () => {
this.revoking = { ...this.revoking, [id]: true } this.revoking = { ...this.revoking, [id]: true }
try { try {
const res = await apiFetch(`/api/auth/sessions/${id}`, { method: 'DELETE' }) const res = await apiFetch(`/api/auth/sessions/${id}`, { method: 'DELETE' })
if (res.ok) this.sessions = this.sessions.filter(s => s.id !== id) if (res.ok) {
} catch (e) { /* ignore */ } 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 } this.revoking = { ...this.revoking, [id]: false }
}, },
@ -673,9 +747,15 @@ document.addEventListener('alpine:init', () => {
const key = `${vmid}-${action}` const key = `${vmid}-${action}`
this.actionLoading[key] = true this.actionLoading[key] = true
try { 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) { } catch(e) {
console.error(e) Alpine.store('toasts').error(`Erreur réseau — ${e.message}`)
} finally { } finally {
this.actionLoading[key] = false this.actionLoading[key] = false
} }