core/frontend/js/app.js
enzo 65c8bf332f fix: access_token (pas token) dans la réponse login/refresh
Le backend retourne { access_token: "...", user: {...} } pas { token: "..." }.
Le store Alpine lisait data.token → undefined → stockait "undefined" en localStorage
→ toutes les requêtes API échouaient avec 401.

Corrigé dans login() et tryRefresh().
Ajout d'un guard synchrone immédiat (pas de token → redirect login sans attendre fetchMe).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:50:52 +01:00

712 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ProxmoxPanel — Alpine.js stores + composants + Swup init + HTMX config
*
* Chargé AVANT alpine.min.js (qui est defer).
* L'événement 'alpine:init' est déclenché par Alpine avant qu'il parcourt le DOM.
*/
// ── Utilitaires ────────────────────────────────────────────────────────────
function apiFetch(path, opts = {}) {
const token = localStorage.getItem('pxp_token')
return fetch(path, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...(opts.headers || {}),
},
})
}
// ── Alpine:init ────────────────────────────────────────────────────────────
document.addEventListener('alpine:init', () => {
// ── Store auth ─────────────────────────────────────────────────────────
Alpine.store('auth', {
token: null,
user: null,
get isAuthenticated() { return !!this.token && !!this.user },
async init() {
this.token = localStorage.getItem('pxp_token')
if (this.token) {
try {
await this.fetchMe()
} catch {
await this.tryRefresh()
}
}
},
async fetchMe() {
const res = await apiFetch('/api/auth/me')
if (res.ok) {
this.user = await res.json()
} else if (res.status === 401) {
await this.tryRefresh()
}
},
async tryRefresh() {
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 {
this.clear()
}
},
async login(username, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Identifiants invalides')
}
const data = await res.json()
// Le backend retourne "access_token" (pas "token")
this.token = data.access_token
this.user = data.user
localStorage.setItem('pxp_token', data.access_token)
},
async logout() {
await apiFetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
this.clear()
window.location.href = '/login.html'
},
clear() {
this.token = null
this.user = null
localStorage.removeItem('pxp_token')
},
})
// ── Store UI ────────────────────────────────────────────────────────────
Alpine.store('ui', {
theme: localStorage.getItem('pxp_theme') || 'dark',
sidebarCollapsed: localStorage.getItem('pxp_sidebar') === 'true',
currentPage: '',
init() {
this.applyTheme()
// Detect current page from URL
const path = window.location.pathname
this.currentPage = path.replace(/^\/|\.html$/g, '') || 'index'
},
applyTheme() {
document.documentElement.setAttribute('data-theme', this.theme)
},
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark'
localStorage.setItem('pxp_theme', this.theme)
this.applyTheme()
},
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed
localStorage.setItem('pxp_sidebar', this.sidebarCollapsed)
},
})
// ── Store i18n ──────────────────────────────────────────────────────────
Alpine.store('i18n', {
lang: localStorage.getItem('pxp_lang') || 'fr',
msgs: {},
loaded: false,
async init() {
await this.load(this.lang)
},
async load(lang) {
try {
const res = await fetch(`/locales/${lang}.json`)
if (res.ok) {
this.msgs = await res.json()
this.lang = lang
localStorage.setItem('pxp_lang', lang)
this.loaded = true
}
} catch (e) {
console.error('i18n load error', e)
}
},
t(key, vars = {}) {
const parts = key.split('.')
let val = this.msgs
for (const p of parts) {
val = val?.[p]
if (val === undefined) return key
}
if (typeof val !== 'string') return key
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
},
async setLang(lang) {
await this.load(lang)
},
})
// ── Composant: sidebar ──────────────────────────────────────────────────
Alpine.data('sidebar', () => ({
get collapsed() { return Alpine.store('ui').sidebarCollapsed },
get currentPage() { return Alpine.store('ui').currentPage },
navItems: [
{ id: 'dashboard', icon: '⊞', labelKey: 'nav.dashboard', href: '/dashboard.html' },
{ id: 'proxmox', icon: '⬡', labelKey: 'nav.proxmox', href: '/proxmox.html' },
{ id: 'updates', icon: '↑', labelKey: 'nav.updates', href: '/updates.html' },
{ id: 'terminal', icon: '', labelKey: 'nav.terminal', href: '/terminal.html' },
{ id: 'settings', icon: '⚙', labelKey: 'nav.settings', href: '/settings.html' },
{ id: 'modules', icon: '⬡', labelKey: 'nav.modules', href: '/modules.html' },
],
isActive(id) {
return this.currentPage === id
},
navigate(href) {
if (window.swup) {
window.swup.navigate(href)
} else {
window.location.href = href
}
},
t(key) { return Alpine.store('i18n').t(key) },
toggle() { Alpine.store('ui').toggleSidebar() },
}))
// ── Composant: navbar ───────────────────────────────────────────────────
Alpine.data('navbar', () => ({
get theme() { return Alpine.store('ui').theme },
get user() { return Alpine.store('auth').user },
get lang() { return Alpine.store('i18n').lang },
toggleTheme() { Alpine.store('ui').toggleTheme() },
logout() { Alpine.store('auth').logout() },
async setLang(lang) { await Alpine.store('i18n').setLang(lang) },
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: loginPage ────────────────────────────────────────────────
Alpine.data('loginPage', () => ({
username: '',
password: '',
error: '',
loading: false,
async submit() {
this.error = ''
this.loading = true
try {
await Alpine.store('auth').login(this.username, this.password)
window.location.href = '/dashboard.html'
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: installPage ──────────────────────────────────────────────
Alpine.data('installPage', () => ({
step: 1,
totalSteps: 4,
error: '',
loading: false,
sshTesting: false,
sshStatus: '',
form: {
instance_name: 'ProxmoxPanel',
public_url: window.location.origin,
default_lang: 'fr',
ssh_host: '',
ssh_username: '',
ssh_password: '',
proxmox_url: '',
proxmox_token_id: '',
proxmox_token_secret: '',
},
async testSSH() {
this.sshTesting = true
this.sshStatus = ''
try {
const res = await fetch('/api/install/test-ssh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: this.form.ssh_host,
username: this.form.ssh_username,
password: this.form.ssh_password,
}),
})
this.sshStatus = res.ok ? 'ok' : 'error'
} catch {
this.sshStatus = 'error'
} finally {
this.sshTesting = false
}
},
nextStep() {
if (this.step < this.totalSteps) this.step++
},
prevStep() {
if (this.step > 1) this.step--
},
async finish() {
this.loading = true
this.error = ''
try {
const res = await fetch('/api/install/configure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
})
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(d.error || 'Erreur installation')
}
window.location.href = '/login.html'
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
t(key, vars) { return Alpine.store('i18n').t(key, vars) },
}))
// ── Composant: dashboardPage ────────────────────────────────────────────
Alpine.data('dashboardPage', () => ({
resources: [],
ws: null,
wsStatus: 'connecting',
init() {
this.connectWS()
},
destroy() {
if (this.ws) this.ws.close()
},
connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'proxmox_resources') {
this.resources = msg.data || []
this.wsStatus = 'ok'
}
}
this.ws.onclose = () => {
this.wsStatus = 'disconnected'
setTimeout(() => this.connectWS(), 5000)
}
this.ws.onerror = () => { this.wsStatus = 'error' }
},
get runningCount() { return this.resources.filter(r => r.status === 'running').length },
get stoppedCount() { return this.resources.filter(r => r.status !== 'running').length },
get lxcList() { return this.resources.filter(r => r.type === 'lxc') },
get vmList() { return this.resources.filter(r => r.type === 'qemu') },
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: proxmoxPage ──────────────────────────────────────────────
Alpine.data('proxmoxPage', () => ({
resources: [],
ws: null,
wsStatus: 'connecting',
actionLoading: {},
init() { this.connectWS() },
destroy() { if (this.ws) this.ws.close() },
connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = encodeURIComponent(localStorage.getItem('pxp_token') || '')
this.ws = new WebSocket(`${proto}://${location.host}/ws/proxmox?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'proxmox_resources') {
this.resources = msg.data || []
this.wsStatus = 'ok'
}
}
this.ws.onclose = () => {
this.wsStatus = 'disconnected'
setTimeout(() => this.connectWS(), 5000)
}
this.ws.onerror = () => { this.wsStatus = 'error' }
},
async action(vmid, type, action) {
const key = `${vmid}-${action}`
this.actionLoading[key] = true
try {
await apiFetch(`/api/proxmox/${type}/${vmid}/${action}`, { method: 'POST' })
} catch(e) {
console.error(e)
} finally {
this.actionLoading[key] = false
}
},
cpuColor(pct) {
if (pct > 80) return 'var(--neu-danger)'
if (pct > 50) return 'var(--neu-warning)'
return 'var(--neu-success)'
},
formatMem(bytes) {
if (!bytes) return '0 MB'
return Math.round(bytes / 1024 / 1024) + ' MB'
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: updatesPage ──────────────────────────────────────────────
Alpine.data('updatesPage', () => ({
targets: [],
loading: true,
ws: null,
currentJob: null,
output: '',
jobStatus: '',
async init() {
await this.loadTargets()
await this.checkAll()
},
destroy() { if (this.ws) this.ws.close() },
async loadTargets() {
this.loading = true
try {
const res = await apiFetch('/api/proxmox/resources')
if (res.ok) {
const resources = await res.json() || []
this.targets = [
{ id: 'host', name: 'Proxmox Host', status: 'running', packages: null, checking: false, updating: false },
...resources
.filter(r => r.type === 'lxc')
.map(r => ({
id: `lxc:${r.vmid}`,
name: r.name || `LXC ${r.vmid}`,
status: r.status,
vmid: r.vmid,
packages: null,
checking: false,
updating: false,
})),
]
}
} catch(e) {
console.error('loadTargets', e)
} finally {
this.loading = false
}
},
async checkTarget(target) {
target.checking = true
target.packages = null
try {
const res = await apiFetch(`/api/updates/packages?target=${encodeURIComponent(target.id)}`)
if (res.ok) {
target.packages = await res.json()
}
} catch(e) {
console.error('checkTarget', e)
} finally {
target.checking = false
}
},
async checkAll() {
for (const t of this.targets) {
await this.checkTarget(t)
}
},
async updateTarget(target) {
target.updating = true
this.output = ''
this.jobStatus = 'running'
try {
const res = await apiFetch('/api/updates/run', {
method: 'POST',
body: JSON.stringify({ target: target.id }),
})
if (!res.ok) throw new Error('Erreur démarrage mise à jour')
const data = await res.json()
this.currentJob = data.job_id
await this.watchJob(data.job_id)
target.packages = []
} catch(e) {
this.output += '\n[ERREUR] ' + e.message
this.jobStatus = 'error'
} finally {
target.updating = false
}
},
async updateAll() {
this.output = ''
this.jobStatus = 'running'
try {
const res = await apiFetch('/api/updates/run', {
method: 'POST',
body: JSON.stringify({ target: 'all' }),
})
if (!res.ok) throw new Error('Erreur démarrage')
const data = await res.json()
this.currentJob = data.job_id
await this.watchJob(data.job_id)
for (const t of this.targets) t.packages = []
} catch(e) {
this.output += '\n[ERREUR] ' + e.message
this.jobStatus = 'error'
}
},
watchJob(jobId) {
return new Promise((resolve) => {
if (this.ws) this.ws.close()
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const token = localStorage.getItem('pxp_token')
this.ws = new WebSocket(`${proto}://${location.host}/ws/updates/${jobId}?token=${token}`)
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
if (msg.type === 'update_output') {
this.output += msg.data?.chunk || ''
} else if (msg.type === 'update_done') {
this.jobStatus = 'success'
resolve()
} else if (msg.type === 'update_error') {
this.jobStatus = 'error'
this.output += '\n[ERREUR] ' + (msg.data?.error || '')
resolve()
}
}
this.ws.onerror = () => { this.jobStatus = 'error'; resolve() }
this.ws.onclose = () => { if (this.jobStatus === 'running') { this.jobStatus = 'error'; resolve() } }
})
},
get totalPackages() {
return this.targets.reduce((sum, t) => sum + (t.packages?.length || 0), 0)
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: settingsPage ─────────────────────────────────────────────
Alpine.data('settingsPage', () => ({
tab: 'general',
loading: true,
saving: false,
saved: false,
error: '',
settings: {
instance_name: '',
public_url: '',
default_lang: 'fr',
ssh_host: '',
ssh_username: '',
ssh_password: '',
proxmox_url: '',
proxmox_token_id: '',
proxmox_token_secret: '',
},
async init() {
await this.load()
},
async load() {
this.loading = true
try {
const res = await apiFetch('/api/settings')
if (res.ok) {
const data = await res.json()
Object.assign(this.settings, data)
}
} finally {
this.loading = false
}
},
async save() {
this.saving = true
this.saved = false
this.error = ''
try {
const res = await apiFetch('/api/settings', {
method: 'PUT',
body: JSON.stringify(this.settings),
})
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(d.error || 'Erreur sauvegarde')
}
this.saved = true
setTimeout(() => { this.saved = false }, 3000)
} catch(e) {
this.error = e.message
} finally {
this.saving = false
}
},
t(key) { return Alpine.store('i18n').t(key) },
}))
// ── Composant: modulesPage ──────────────────────────────────────────────
Alpine.data('modulesPage', () => ({
modules: [],
loading: true,
toggling: {},
async init() {
await this.load()
},
async load() {
this.loading = true
try {
const res = await apiFetch('/api/modules')
if (res.ok) {
this.modules = await res.json() || []
}
} finally {
this.loading = false
}
},
async toggle(mod) {
this.toggling[mod.id] = true
try {
const action = mod.enabled ? 'disable' : 'enable'
const res = await apiFetch(`/api/modules/${mod.id}/${action}`, { method: 'POST' })
if (res.ok) {
mod.enabled = !mod.enabled
}
} catch(e) {
console.error(e)
} finally {
this.toggling[mod.id] = false
}
},
t(key) { return Alpine.store('i18n').t(key) },
}))
}) // end alpine:init
// ── DOMContentLoaded : init stores + Swup ─────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// Init stores
await Alpine.store('i18n').init()
await Alpine.store('auth').init()
Alpine.store('ui').init()
const publicPages = ['login', 'install', 'index', '']
const currentPage = window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
// Guard rapide (synchrone) : si pas de token du tout, redirect immédiat
if (!publicPages.includes(currentPage) && !localStorage.getItem('pxp_token')) {
window.location.href = '/login.html'
return
}
// Redirect depuis index
if (currentPage === 'index' || currentPage === '') {
const res = await fetch('/api/install/status').catch(() => null)
if (res && res.ok) {
const data = await res.json().catch(() => ({}))
window.location.href = data.installed ? '/login.html' : '/install.html'
} else {
window.location.href = '/login.html'
}
return
}
// Init Swup pour transitions de page
if (typeof Swup !== 'undefined') {
const swup = new Swup({
containers: ['#swup'],
animationSelector: '[class*="transition-"]',
})
window.swup = swup
// Guard auth sur navigation
swup.hooks.on('visit:start', (visit) => {
const dest = new URL(visit.to.url, location.href).pathname
.replace(/^\/|\.html$/g, '') || 'index'
if (!publicPages.includes(dest) && !Alpine.store('auth').isAuthenticated) {
visit.abort()
window.location.href = '/login.html'
}
})
// Destroy Alpine scope de l'ancien contenu AVANT le swap
swup.hooks.on('animation:out:end', () => {
const container = document.getElementById('swup')
if (container && typeof Alpine.destroyTree === 'function') {
Alpine.destroyTree(container)
}
})
// Init Alpine sur le nouveau contenu APRÈS le swap
swup.hooks.on('content:replace', () => {
const container = document.getElementById('swup')
if (container) {
Alpine.initTree(container)
}
// Update current page dans UI store
Alpine.store('ui').currentPage =
window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
})
}
// HTMX : inject Authorization header sur toutes les requêtes
document.addEventListener('htmx:configRequest', (e) => {
const token = localStorage.getItem('pxp_token')
if (token) {
e.detail.headers['Authorization'] = 'Bearer ' + token
}
})
})