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:
parent
21e1e0ed1e
commit
dc0c67b89c
3 changed files with 151 additions and 11 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue