diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 2508678..9bb2510 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -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, diff --git a/frontend/css/pages.css b/frontend/css/pages.css index 83ef230..80ec9fa 100644 --- a/frontend/css/pages.css +++ b/frontend/css/pages.css @@ -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 { diff --git a/frontend/js/app.js b/frontend/js/app.js index 7ef7708..e53ca5a 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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 = ` + + ` + 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 }