feat: sessions management, web manifest, square icon-only buttons, remove lang select

- Backend: migration 002 adds user_agent/ip/last_used_at to refresh_tokens
- Backend: GET /api/auth/sessions + DELETE /api/auth/sessions/{id} endpoints
- Frontend: profile page — sessions section (browser, IP, datetime, revoke)
- Frontend: web manifest + SVG icon for PWA support
- Frontend: remove language selector from all navbars (moved to profile page)
- Frontend: neu-btn--icon-sm class for square icon-only buttons (theme/logout/edit)
- Frontend: manifest link added to all 9 HTML pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-21 20:14:11 +01:00
parent b851dc61af
commit 97212b7ffa
18 changed files with 280 additions and 42 deletions

View file

@ -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))

View file

@ -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;

View file

@ -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)

View file

@ -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/')

View file

@ -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 <i> (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;

View file

@ -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;

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
@ -42,14 +43,11 @@
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.dashboard')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()"
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()"
:title="theme==='dark' ? t('navbar.lightMode') : t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
@ -59,7 +57,7 @@
<div class="page-header">
<h2 class="page-title" x-text="t('dashboard.welcome').replace('{name}', $store.auth.user?.username || '')"></h2>
<button class="neu-btn neu-btn--sm" :class="{ 'neu-btn--primary': editMode }"
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" :class="{ 'neu-btn--primary': editMode }"
@click="toggleEdit()" title="Mode édition">
<i class="lnid-pencil-1"></i>
</button>

24
frontend/icons/icon.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#16213e"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6c8ef4"/>
<stop offset="100%" style="stop-color:#a78bfa"/>
</linearGradient>
</defs>
<!-- Background rounded square -->
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<!-- Server rack lines -->
<rect x="96" y="140" width="320" height="64" rx="12" fill="none" stroke="url(#accent)" stroke-width="10" opacity="0.4"/>
<rect x="96" y="224" width="320" height="64" rx="12" fill="none" stroke="url(#accent)" stroke-width="10" opacity="0.6"/>
<rect x="96" y="308" width="320" height="64" rx="12" fill="none" stroke="url(#accent)" stroke-width="10" opacity="0.4"/>
<!-- Status dots -->
<circle cx="142" cy="172" r="8" fill="#22c55e"/>
<circle cx="142" cy="256" r="8" fill="url(#accent)"/>
<circle cx="142" cy="340" r="8" fill="#22c55e"/>
<!-- P letter -->
<text x="256" y="300" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="800" font-size="200" fill="url(#accent)" opacity="0.12">P</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>

View file

@ -526,11 +526,15 @@ document.addEventListener('alpine:init', () => {
theme: '',
sidebarPosition: '',
lang: '',
sessions: [],
sessionsLoading: true,
revoking: {},
init() {
async init() {
this.theme = Alpine.store('ui').theme
this.sidebarPosition = Alpine.store('ui').sidebarPosition
this.lang = Alpine.store('i18n').lang
await this.loadSessions()
},
setTheme(t) {
@ -550,6 +554,40 @@ document.addEventListener('alpine:init', () => {
await Alpine.store('i18n').setLang(lang)
},
async loadSessions() {
this.sessionsLoading = true
try {
const res = await apiFetch('/api/auth/sessions')
if (res.ok) this.sessions = await res.json()
} catch (e) { /* ignore */ }
this.sessionsLoading = false
},
async revokeSession(id) {
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 */ }
this.revoking = { ...this.revoking, [id]: false }
},
formatDate(iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
},
parseUA(ua) {
if (!ua) return 'Navigateur inconnu'
if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${RegExp.$1}`
if (/Chrome\/(\d+)/i.test(ua) && !/Chromium|Edge|OPR/i.test(ua)) return `Chrome ${RegExp.$1}`
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${RegExp.$1}`
if (/Safari\//i.test(ua) && !/Chrome/i.test(ua)) return 'Safari'
if (/OPR\/(\d+)/i.test(ua)) return `Opera ${RegExp.$1}`
return ua.slice(0, 40)
},
get user() { return Alpine.store('auth').user },
t(key) { return Alpine.store('i18n').t(key) },
}))

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>

17
frontend/manifest.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "ProxmoxPanel",
"short_name": "PxPanel",
"description": "Interface de gestion Proxmox",
"start_url": "/dashboard.html",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#6c8ef4",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
@ -42,13 +43,10 @@
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.modules')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
@ -42,13 +43,10 @@
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.profile')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>
@ -141,6 +139,47 @@
</div>
</div>
<!-- Sessions actives -->
<div class="neu-card settings-section">
<h3 class="section-title">
<i class="lnid-key-1"></i>
Sessions actives
</h3>
<div class="loading-state" x-show="sessionsLoading">
<div class="spinner-lg"></div>
</div>
<div x-show="!sessionsLoading">
<template x-if="sessions.length === 0">
<p class="empty-state">Aucune session active</p>
</template>
<template x-for="s in sessions" :key="s.id">
<div class="session-row">
<div class="session-info">
<div class="session-browser">
<i class="lnid-laptop-1"></i>
<span x-text="parseUA(s.user_agent)"></span>
</div>
<div class="session-meta">
<span class="session-ip" x-text="s.ip"></span>
<span class="session-sep">·</span>
<span class="session-date" x-text="formatDate(s.last_used_at || s.created_at)"></span>
<span class="session-sep">·</span>
<span class="session-id" x-text="'#' + s.id"></span>
</div>
</div>
<button class="neu-btn neu-btn--sm neu-btn--danger neu-btn--icon-sm"
@click="revokeSession(s.id)"
:disabled="revoking[s.id]"
title="Révoquer cette session">
<i class="lnid-cross"></i>
</button>
</div>
</template>
</div>
</div>
<!-- Déconnexion -->
<div class="neu-card settings-section">
<button class="neu-btn neu-btn--danger" @click="$store.auth.logout()">

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
@ -42,13 +43,10 @@
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.proxmox')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
@ -42,13 +43,10 @@
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.settings')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/css/xterm.css" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
@ -45,13 +46,10 @@
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.terminal')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="/css/light.css" />
<link rel="stylesheet" href="/css/pages.css" />
<link rel="stylesheet" href="/css/lineicons-duotone.css" />
<link rel="manifest" href="/manifest.json" />
<script src="/js/vendors/htmx.min.js"></script>
<script src="/js/vendors/swup.iife.js"></script>
<script src="/js/app.js"></script>
@ -42,13 +43,10 @@
<nav class="navbar" x-data="navbar()" x-cloak>
<h2 class="navbar-title" x-text="t('nav.updates')"></h2>
<div class="navbar-actions">
<select class="neu-input neu-input--sm" x-model="lang" @change="setLang($event.target.value)">
<option value="fr">FR</option><option value="en">EN</option>
</select>
<button class="neu-btn neu-btn--sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="toggleTheme()" :title="theme==='dark'?t('navbar.lightMode'):t('navbar.darkMode')">
<i :class="theme==='dark' ? 'lnid-sun-1' : 'lnid-moon-half-left-1'"></i>
</button>
<button class="neu-btn neu-btn--sm" @click="logout()" :title="t('navbar.logout')">
<button class="neu-btn neu-btn--sm neu-btn--icon-sm" @click="logout()" :title="t('navbar.logout')">
<i class="lnid-power-button"></i>
</button>
</div>