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
|
||||
// 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"
|
||||
// Path élargi à /api/auth/ pour que le cookie soit envoyé au logout aussi
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pxp_refresh",
|
||||
Value: refreshToken,
|
||||
Path: "/api/auth/refresh",
|
||||
Path: "/api/auth/",
|
||||
HttpOnly: true,
|
||||
Secure: isHTTPS,
|
||||
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
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
claims := GetClaims(r)
|
||||
|
||||
// Supprimer tous les refresh tokens de cet utilisateur
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +165,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pxp_refresh",
|
||||
Value: "",
|
||||
Path: "/api/auth/refresh",
|
||||
Path: "/api/auth/",
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
|
|
|
|||
|
|
@ -763,6 +763,57 @@
|
|||
.history-status.success { color: var(--neu-success); }
|
||||
.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 ───────────────────────────────────────────────────── */
|
||||
.shortcuts-editor { display: flex; flex-direction: column; gap: .5rem; }
|
||||
.shortcut-row {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue