feat: label session actuelle + fix bouton révoquer
- GetSessions: retourne is_current=true pour la session correspondant au cookie courant - GetSessions: select token_hash pour la comparaison (non exposé dans le JSON) - profile.html: badge "Session actuelle" + désactive révoquer pour la session courante (utiliser le bouton Déconnexion à la place) - app.js: revokeSession utilise finally pour reset + isRevoking() helper - pages.css: styles .badge-current + .session-current Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1cbd7e9d17
commit
98cdabf3e1
4 changed files with 40 additions and 14 deletions
|
|
@ -347,7 +347,7 @@ func (h *AuthHandler) GetSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := h.db.Query(`
|
rows, err := h.db.Query(`
|
||||||
SELECT id, user_agent, ip, created_at, last_used_at, expires_at
|
SELECT id, user_agent, ip, created_at, last_used_at, expires_at, token_hash
|
||||||
FROM refresh_tokens
|
FROM refresh_tokens
|
||||||
WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP
|
WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP
|
||||||
ORDER BY COALESCE(last_used_at, created_at) DESC
|
ORDER BY COALESCE(last_used_at, created_at) DESC
|
||||||
|
|
@ -358,6 +358,12 @@ func (h *AuthHandler) GetSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Hash du cookie courant pour marquer "session actuelle"
|
||||||
|
currentHash := ""
|
||||||
|
if cookie, err := r.Cookie("pxp_refresh"); err == nil {
|
||||||
|
currentHash = hashToken(cookie.Value)
|
||||||
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserAgent string `json:"user_agent"`
|
UserAgent string `json:"user_agent"`
|
||||||
|
|
@ -365,14 +371,16 @@ func (h *AuthHandler) GetSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastUsedAt *string `json:"last_used_at"`
|
LastUsedAt *string `json:"last_used_at"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
IsCurrent bool `json:"is_current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions := []Session{}
|
sessions := []Session{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Session
|
var s Session
|
||||||
|
var tokenHash string
|
||||||
var createdAt, expiresAt sql.NullString
|
var createdAt, expiresAt sql.NullString
|
||||||
var lastUsedAt sql.NullString
|
var lastUsedAt sql.NullString
|
||||||
if err := rows.Scan(&s.ID, &s.UserAgent, &s.IP, &createdAt, &lastUsedAt, &expiresAt); err != nil {
|
if err := rows.Scan(&s.ID, &s.UserAgent, &s.IP, &createdAt, &lastUsedAt, &expiresAt, &tokenHash); err != nil {
|
||||||
log.Printf("[GetSessions] scan error userID=%d: %v", claims.UserID, err)
|
log.Printf("[GetSessions] scan error userID=%d: %v", claims.UserID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -381,6 +389,7 @@ func (h *AuthHandler) GetSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if lastUsedAt.Valid && lastUsedAt.String != "" {
|
if lastUsedAt.Valid && lastUsedAt.String != "" {
|
||||||
s.LastUsedAt = &lastUsedAt.String
|
s.LastUsedAt = &lastUsedAt.String
|
||||||
}
|
}
|
||||||
|
s.IsCurrent = currentHash != "" && tokenHash == currentHash
|
||||||
sessions = append(sessions, s)
|
sessions = append(sessions, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -725,6 +725,18 @@
|
||||||
.session-sep { opacity: .4; }
|
.session-sep { opacity: .4; }
|
||||||
.session-id { font-family: monospace; opacity: .6; }
|
.session-id { font-family: monospace; opacity: .6; }
|
||||||
|
|
||||||
|
.session-current { background: rgba(var(--neu-primary-rgb, 108,142,244), .05); border-radius: var(--neu-radius); padding-left: .5rem; padding-right: .5rem; }
|
||||||
|
|
||||||
|
.badge-current {
|
||||||
|
font-size: .65rem;
|
||||||
|
padding: .15rem .45rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: rgba(34,197,94,.15);
|
||||||
|
color: var(--neu-success);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Logo auth LineIcons ─────────────────────────────────────────────────────── */
|
/* ── Logo auth LineIcons ─────────────────────────────────────────────────────── */
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
|
|
|
||||||
|
|
@ -670,7 +670,7 @@ document.addEventListener('alpine:init', () => {
|
||||||
},
|
},
|
||||||
|
|
||||||
async revokeSession(id) {
|
async revokeSession(id) {
|
||||||
this.revoking = { ...this.revoking, [id]: true }
|
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) {
|
if (res.ok) {
|
||||||
|
|
@ -682,10 +682,13 @@ document.addEventListener('alpine:init', () => {
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alpine.store('toasts').error(`Erreur réseau — ${e.message}`)
|
Alpine.store('toasts').error(`Erreur réseau — ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
this.revoking[id] = false
|
||||||
}
|
}
|
||||||
this.revoking = { ...this.revoking, [id]: false }
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isRevoking(id) { return this.revoking[id] === true },
|
||||||
|
|
||||||
formatDate(raw) {
|
formatDate(raw) {
|
||||||
if (!raw) return '—'
|
if (!raw) return '—'
|
||||||
// SQLite retourne "YYYY-MM-DD HH:MM:SS" ou RFC3339 selon le driver
|
// SQLite retourne "YYYY-MM-DD HH:MM:SS" ou RFC3339 selon le driver
|
||||||
|
|
|
||||||
|
|
@ -155,14 +155,15 @@
|
||||||
<p class="empty-state">Aucune session active</p>
|
<p class="empty-state">Aucune session active</p>
|
||||||
</template>
|
</template>
|
||||||
<template x-for="s in sessions" :key="s.id">
|
<template x-for="s in sessions" :key="s.id">
|
||||||
<div class="session-row">
|
<div class="session-row" :class="{ 'session-current': s.is_current }">
|
||||||
<div class="session-info">
|
<div class="session-info">
|
||||||
<div class="session-browser">
|
<div class="session-browser">
|
||||||
<i class="lnid-laptop-1"></i>
|
<i class="lnid-laptop-1"></i>
|
||||||
<span x-text="parseUA(s.user_agent)"></span>
|
<span x-text="parseUA(s.user_agent)"></span>
|
||||||
|
<span class="badge badge-current" x-show="s.is_current">Session actuelle</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-meta">
|
<div class="session-meta">
|
||||||
<span class="session-ip" x-text="s.ip"></span>
|
<span class="session-ip" x-text="s.ip || '—'"></span>
|
||||||
<span class="session-sep">·</span>
|
<span class="session-sep">·</span>
|
||||||
<span class="session-date" x-text="formatDate(s.last_used_at || s.created_at)"></span>
|
<span class="session-date" x-text="formatDate(s.last_used_at || s.created_at)"></span>
|
||||||
<span class="session-sep">·</span>
|
<span class="session-sep">·</span>
|
||||||
|
|
@ -171,9 +172,10 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="neu-btn neu-btn--sm neu-btn--danger neu-btn--icon-sm"
|
<button class="neu-btn neu-btn--sm neu-btn--danger neu-btn--icon-sm"
|
||||||
@click="revokeSession(s.id)"
|
@click="revokeSession(s.id)"
|
||||||
:disabled="revoking[s.id]"
|
:disabled="isRevoking(s.id) || s.is_current"
|
||||||
title="Révoquer cette session">
|
:title="s.is_current ? 'Utilisez le bouton Déconnexion pour cette session' : 'Révoquer cette session'">
|
||||||
<i class="lnid-cross"></i>
|
<i x-show="!isRevoking(s.id)" class="lnid-cross"></i>
|
||||||
|
<span x-show="isRevoking(s.id)" class="spinner-sm"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue