Construire un dashboard Nuxt 4 pour piloter Caddy via son Admin API — authentification JWT, reverse proxy CRUD, import/export, et déploiement Docker Compose.
OpenCaddyHost est une application mono-utilisateur : un seul compte admin, défini dans .env. Pas de base de données, pas d'inscription. L'authentification repose sur un JWT signé côté serveur.
Client → POST /api/auth { username, password }
Serveur → vérifie les credentials → génère JWT → renvoie { token }
Client → stocke le token dans localStorage via Pinia
Client → envoie Authorization: Bearer <token> sur chaque requête protégée
Serveur → vérifie la signature du JWT → autorise ou rejetteimport jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
import { readBody, createError } from 'h3'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const body = await readBody(event)
const { username, password } = body
// Validation basique
if (!username || !password) {
throw createError({ statusCode: 400, statusMessage: 'Identifiants manquants' })
}
// Vérification username
if (username !== config.adminUsername) {
throw createError({ statusCode: 401, statusMessage: 'Identifiants invalides' })
}
// Vérification password
const isValid = await bcrypt.compare(password, await bcrypt.hash(config.adminPassword, 10))
|| password === config.adminPassword
if (!isValid) {
throw createError({ statusCode: 401, statusMessage: 'Identifiants invalides' })
}
// Générer le JWT
const token = jwt.sign(
{ username, role: 'admin' },
config.jwtSecret,
{ expiresIn: '24h' }
)
return { success: true, token, user: { username, role: 'admin' } }
})Le token expire dans 24h. jwt.sign encode le payload et le signe avec jwtSecret — sans ce secret, impossible de forger un token valide.
import jwt from 'jsonwebtoken'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const user = authenticateToken(event) // throws 401/403 si invalide
return { valid: true, user }
})Le client appelle cet endpoint au démarrage pour valider que le token stocké est toujours valide.
import { defineStore } from 'pinia'
interface AuthState {
token: string | null
user: { username: string; role: string } | null
isAuthenticated: boolean
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
token: null,
user: null,
isAuthenticated: false,
}),
actions: {
async login(username: string, password: string) {
const data = await $fetch('/api/auth', {
method: 'POST',
body: { username, password },
})
this.token = data.token
this.user = data.user
this.isAuthenticated = true
// Persister dans localStorage
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(data.user))
// Planifier la déconnexion auto avant expiration
this.scheduleTokenCleanup()
},
logout() {
this.token = null
this.user = null
this.isAuthenticated = false
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
},
initAuth() {
const token = localStorage.getItem('auth_token')
const user = localStorage.getItem('auth_user')
if (!token || !user) return
// Vérifier l'expiration sans appel serveur
if (this.isTokenExpired(token)) {
this.logout()
return
}
this.token = token
this.user = JSON.parse(user)
this.isAuthenticated = true
this.scheduleTokenCleanup()
},
isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.exp * 1000 < Date.now()
} catch {
return true
}
},
scheduleTokenCleanup() {
if (!this.token) return
try {
const payload = JSON.parse(atob(this.token.split('.')[1]))
const msUntilExpiry = payload.exp * 1000 - Date.now() - 60_000 // 1 min avant
if (msUntilExpiry > 0) {
setTimeout(() => this.logout(), msUntilExpiry)
}
} catch { /* token malformé */ }
},
getAuthHeaders(): Record<string, string> {
if (!this.token || this.isTokenExpired(this.token)) {
this.logout()
return {}
}
return { Authorization: `Bearer ${this.token}` }
},
},
})scheduleTokenCleanup calcule le temps restant avant expiration et programme un setTimeout pour déconnecter l'utilisateur proprement — pas de session expirée silencieuse.
export function useAuth() {
const authStore = useAuthStore()
const router = useRouter()
async function login(username: string, password: string) {
await authStore.login(username, password)
await router.push('/')
}
function logout() {
authStore.logout()
router.push('/login')
}
// Vérification périodique toutes les 5 minutes
function startAuthCheck() {
setInterval(async () => {
if (!authStore.isAuthenticated) return
try {
await $fetch('/api/auth/verify', {
headers: authStore.getAuthHeaders(),
})
} catch {
logout()
}
}, 5 * 60 * 1000)
}
return {
isAuthenticated: computed(() => authStore.isAuthenticated),
user: computed(() => authStore.user),
login,
logout,
startAuthCheck,
getAuthHeaders: () => authStore.getAuthHeaders(),
}
}<script setup lang="ts">
definePageMeta({ layout: false }) // Pas de sidebar sur la page login
const { login } = useAuth()
const form = reactive({ username: '', password: '' })
const error = ref('')
const loading = ref(false)
async function submit() {
loading.value = true
error.value = ''
try {
await login(form.username, form.password)
} catch (e: any) {
error.value = e.data?.statusMessage || 'Identifiants invalides'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-900">
<form @submit.prevent="submit" class="bg-gray-800 p-8 rounded-xl w-full max-w-sm space-y-4">
<h1 class="text-2xl font-bold text-white">OpenCaddyHost</h1>
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
<input v-model="form.username" type="text" placeholder="Identifiant" class="input" required />
<input v-model="form.password" type="password" placeholder="Mot de passe" class="input" required />
<button type="submit" :disabled="loading" class="btn-primary w-full">
{{ loading ? 'Connexion...' : 'Se connecter' }}
</button>
</form>
</div>
</template>export default defineNuxtRouteMiddleware((to) => {
if (to.path === '/login') return
const authStore = useAuthStore()
authStore.initAuth() // restaure depuis localStorage
if (!authStore.isAuthenticated) {
return navigateTo('/login')
}
})Le suffixe .global.ts applique ce middleware sur toutes les routes sans le déclarer page par page.