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

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