diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 92359e1..625e0e6 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -12,6 +12,7 @@ import ( "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" "git.geronzi.fr/proxmoxPanel/core/backend/internal/auth" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" + "github.com/go-chi/chi/v5" ) // AuthHandler contient les handlers d'authentification. @@ -107,8 +108,9 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { tokenHash := hashToken(refreshToken) expiry := time.Now().Add(auth.RefreshTokenDuration()) h.db.Exec(` - INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?) - `, userID, tokenHash, expiry) + INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip, last_used_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, userID, tokenHash, expiry, r.UserAgent(), clientIP(r)) // Mettre à jour la date de dernier login h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID) @@ -185,6 +187,9 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { return } + // Mettre à jour la date de dernière utilisation + h.db.Exec(`UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, tokenHash) + // Récupérer les infos utilisateur var username string var isAdmin int @@ -305,6 +310,77 @@ func (h *AuthHandler) upsertUser(info *auth.UserInfo) (int64, error) { return id, err } +// GetSessions retourne les sessions actives de l'utilisateur connecté. +// GET /api/auth/sessions +func (h *AuthHandler) GetSessions(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + if claims == nil { + JSONError(w, "Non authentifié", http.StatusUnauthorized) + return + } + + rows, err := h.db.Query(` + SELECT id, user_agent, ip, created_at, last_used_at, expires_at + FROM refresh_tokens + WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP + ORDER BY COALESCE(last_used_at, created_at) DESC + `, claims.UserID) + if err != nil { + JSONError(w, "Erreur récupération sessions", http.StatusInternalServerError) + return + } + defer rows.Close() + + type Session struct { + ID int64 `json:"id"` + UserAgent string `json:"user_agent"` + IP string `json:"ip"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt time.Time `json:"expires_at"` + } + + sessions := []Session{} + for rows.Next() { + var s Session + var lastUsed sql.NullTime + if err := rows.Scan(&s.ID, &s.UserAgent, &s.IP, &s.CreatedAt, &lastUsed, &s.ExpiresAt); err != nil { + continue + } + if lastUsed.Valid { + s.LastUsedAt = &lastUsed.Time + } + sessions = append(sessions, s) + } + + JSONResponse(w, http.StatusOK, sessions) +} + +// RevokeSession révoque une session (refresh token) de l'utilisateur connecté. +// DELETE /api/auth/sessions/{id} +func (h *AuthHandler) RevokeSession(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r) + if claims == nil { + JSONError(w, "Non authentifié", http.StatusUnauthorized) + return + } + + sessionID := chi.URLParam(r, "id") + res, err := h.db.Exec(`DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?`, sessionID, claims.UserID) + if err != nil { + JSONError(w, "Erreur révocation session", http.StatusInternalServerError) + return + } + n, _ := res.RowsAffected() + if n == 0 { + JSONError(w, "Session introuvable", http.StatusNotFound) + return + } + + h.auditLogger.Log(&claims.UserID, claims.Username, "session_revoked", sessionID, nil, clientIP(r)) + JSONResponse(w, http.StatusOK, map[string]string{"message": "Session révoquée"}) +} + // hashToken crée un hash SHA-256 d'un token pour le stockage en base. func hashToken(token string) string { h := sha256.Sum256([]byte(token)) diff --git a/backend/internal/db/migrations/002_sessions.sql b/backend/internal/db/migrations/002_sessions.sql new file mode 100644 index 0000000..e56b4e0 --- /dev/null +++ b/backend/internal/db/migrations/002_sessions.sql @@ -0,0 +1,6 @@ +-- Migration 002 : Infos de session dans refresh_tokens +-- Ajout user_agent, ip, last_used_at pour la gestion des sessions + +ALTER TABLE refresh_tokens ADD COLUMN user_agent TEXT NOT NULL DEFAULT ''; +ALTER TABLE refresh_tokens ADD COLUMN ip TEXT NOT NULL DEFAULT ''; +ALTER TABLE refresh_tokens ADD COLUMN last_used_at DATETIME; diff --git a/backend/main.go b/backend/main.go index d4242da..e29892d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -130,6 +130,8 @@ func main() { r.Post("/api/auth/logout", authHandler.Logout) r.Get("/api/auth/me", authHandler.Me) r.Patch("/api/auth/preferences", authHandler.UpdatePreferences) + r.Get("/api/auth/sessions", authHandler.GetSessions) + r.Delete("/api/auth/sessions/{id}", authHandler.RevokeSession) // Proxmox r.Get("/api/proxmox/resources", proxmoxHandler.GetResources) diff --git a/frontend/build.mjs b/frontend/build.mjs index 86504a8..329d057 100644 --- a/frontend/build.mjs +++ b/frontend/build.mjs @@ -93,4 +93,19 @@ for (const f of fs.readdirSync('.')) { } } +// 8. Copy manifest.json +if (fs.existsSync('manifest.json')) { + fs.copyFileSync('manifest.json', `${dist}/manifest.json`) + console.log('Copied manifest.json') +} + +// 9. Copy icons/ +if (fs.existsSync('icons')) { + fs.mkdirSync(`${dist}/icons`, { recursive: true }) + for (const f of fs.readdirSync('icons')) { + fs.copyFileSync(`icons/${f}`, `${dist}/icons/${f}`) + } + console.log('Copied icons/') +} + console.log('✓ Build complete → dist/') diff --git a/frontend/css/neu.css b/frontend/css/neu.css index 620d7e3..ef4b0c6 100644 --- a/frontend/css/neu.css +++ b/frontend/css/neu.css @@ -215,9 +215,8 @@ input, select, textarea { font-family: inherit; font-size: inherit; } border-radius: var(--neu-radius-md); } -/* Bouton icône carré taille sm — se déclenche automatiquement si le bouton - ne contient qu'un (ex: theme, logout, edit mode) */ -.neu-btn--sm:has(> i:only-child) { +/* Bouton icône carré (taille sm) */ +.neu-btn.neu-btn--icon-sm { width: 2rem; height: 2rem; padding: 0; diff --git a/frontend/css/pages.css b/frontend/css/pages.css index 1fb9cde..d05ea25 100644 --- a/frontend/css/pages.css +++ b/frontend/css/pages.css @@ -693,6 +693,38 @@ flex-wrap: wrap; } +/* ── Sessions ───────────────────────────────────────────────────────────────── */ +.session-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + padding: .6rem 0; + border-bottom: 1px solid var(--neu-border); +} +.session-row:last-child { border-bottom: none; } + +.session-info { display: flex; flex-direction: column; gap: .2rem; min-width: 0; } + +.session-browser { + display: flex; + align-items: center; + gap: .4rem; + font-size: .9rem; + color: var(--neu-text); + font-weight: 500; +} +.session-meta { + display: flex; + align-items: center; + gap: .3rem; + font-size: .75rem; + color: var(--neu-text-muted); + flex-wrap: wrap; +} +.session-sep { opacity: .4; } +.session-id { font-family: monospace; opacity: .6; } + /* ── Logo auth LineIcons ─────────────────────────────────────────────────────── */ .logo-icon { font-size: 2.5rem; diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 76b29b7..2802048 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -10,6 +10,7 @@ + @@ -42,14 +43,11 @@