feat(auth): migration multi-utilisateurs, rôles admin/user et token API par compte

feat(users): gestion des comptes et système d'invitations avec page d'administration

feat(requests): demandes de séries avec fusion des saisons, vue admin et widget dashboard

feat(jellyfin): synchronisation des comptes, SSO token exchange et API publique v1
This commit is contained in:
MasutaYunikon committed 2026-05-22 20:12:05 +02:00
1 parent dfb515b0f8
commit dabd1d429e
25 files changed
+2865 -225

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
<template>
<div class="flex flex-col gap-6">
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Serveur Jellyfin</h2>
<p class="text-sm text-muted mt-1">
Configurez la connexion à votre serveur Jellyfin pour synchroniser les utilisateurs
et permettre l'authentification depuis le plugin.
</p>
</div>
<div class="settings-card flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">URL du serveur</label>
<input v-model="jellyfinUrl" type="url" class="settings-input" placeholder="http://jellyfin:8096" />
<p class="text-xs text-muted mt-1">URL accessible depuis FanKarr (réseau local ou Docker)</p>
</div>
<div>
<label class="settings-label mb-1.5">Token admin Jellyfin</label>
<input v-model="jellyfinAdminToken" type="password" class="settings-input"
:placeholder="hasToken ? '••••••••••••••••••••• (configuré)' : 'Tableau de bord → Clés API'" />
<p class="text-xs text-muted mt-1">
Dans Jellyfin : Tableau de bord → Clés API → Créer une clé
</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
<button @click="testConnection" :disabled="testing" class="btn-secondary">
{{ testing ? 'Test…' : 'Tester la connexion' }}
</button>
<span v-if="testResult" class="text-xs" :class="testResult.ok ? 'text-green-400' : 'text-red-400'">
{{ testResult.ok ? `Connecté — Jellyfin ${testResult.version}` : testResult.error }}
</span>
<span v-if="saved" class="text-xs text-green-400">Enregistré.</span>
</div>
</div>
</section>
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Synchronisation des utilisateurs</h2>
<p class="text-sm text-muted mt-1">
Importe les utilisateurs Jellyfin en tant que comptes FanKarr.
Les comptes existants sont ignorés. Les utilisateurs pourront ensuite se connecter
via le plugin Jellyfin sans configuration supplémentaire.
</p>
</div>
<div class="settings-card flex flex-col gap-4">
<div v-if="syncResult" class="flex flex-col gap-1">
<p class="text-sm text-primary font-medium">
{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
</p>
<p v-if="syncResult.users.length > 0" class="text-xs text-muted">
Nouveaux : {{ syncResult.users.join(', ') }}
</p>
</div>
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary w-fit disabled:opacity-50">
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
</button>
<p v-if="syncError" class="text-xs text-red-400">{{ syncError }}</p>
<p v-if="!hasToken" class="text-xs text-muted">Configurez d'abord le token admin.</p>
</div>
</section>
<!-- ── Token API utilisateur ──────────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Token API personnel</h2>
<p class="text-sm text-muted mt-1">
Utilisez ce token pour vous authentifier sur l'API publique FanKarr
(ex : configuration manuelle du plugin Jellyfin).
</p>
</div>
<div class="settings-card flex flex-col gap-3">
<div class="flex items-center gap-2">
<code class="flex-1 text-xs bg-shell px-3 py-2 rounded-lg border border-border text-muted font-mono truncate">
{{ showToken ? myToken : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' }}
</code>
<button @click="showToken = !showToken" class="btn-ghost text-xs shrink-0">
{{ showToken ? 'Masquer' : 'Afficher' }}
</button>
<button @click="copyToken" class="btn-ghost text-xs shrink-0">
{{ tokenCopied ? 'Copié !' : 'Copier' }}
</button>
</div>
<button @click="regenerateToken" class="btn-ghost text-xs text-red-400 hover:text-red-300 w-fit">
Regénérer le token
</button>
<p class="text-xs text-muted">
Endpoint auth plugin :
<code class="bg-shell px-1.5 py-0.5 rounded text-xs">POST {{ origin }}/api/v1/auth/jellyfin</code>
</p>
</div>
</section>
<!-- ── Référence API ──────────────────────────────────────── -->
<section class="flex flex-col gap-3">
<h2 class="text-base font-semibold text-primary">Référence API v1</h2>
<div class="settings-card overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-border">
<th class="text-left px-3 py-2 text-muted font-medium">Méthode</th>
<th class="text-left px-3 py-2 text-muted font-medium">Endpoint</th>
<th class="text-left px-3 py-2 text-muted font-medium">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-border/50">
<tr v-for="ep in apiEndpoints" :key="ep.path">
<td class="px-3 py-2">
<span class="font-mono font-semibold" :class="methodClass(ep.method)">{{ ep.method }}</span>
</td>
<td class="px-3 py-2">
<code class="text-muted">{{ ep.path }}</code>
</td>
<td class="px-3 py-2 text-muted">{{ ep.desc }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const jellyfinUrl = ref('')
const jellyfinAdminToken = ref('')
const hasToken = ref(false)
const saving = ref(false)
const saved = ref(false)
const testing = ref(false)
const testResult = ref<{ ok: boolean; version?: string; error?: string } | null>(null)
const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null)
const myToken = ref('')
const showToken = ref(false)
const tokenCopied = ref(false)
const origin = window.location.origin
const apiEndpoints = [
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
{ method: 'GET', path: '/api/v1/series/search?q=', desc: 'Rechercher dans le catalogue FanKai' },
{ method: 'POST', path: '/api/v1/requests', desc: 'Créer ou mettre à jour une demande' },
{ method: 'GET', path: '/api/v1/requests', desc: 'Récupérer ses propres demandes' },
]
function methodClass(m: string) {
return { GET: 'text-green-400', POST: 'text-blue-400', DELETE: 'text-red-400' }[m] ?? 'text-muted'
}
async function loadSettings() {
const res = await fetch('/api/jellyfin/settings', { credentials: 'include' })
if (res.ok) {
const d = await res.json()
jellyfinUrl.value = d.jellyfinUrl ?? ''
hasToken.value = d.hasToken ?? false
}
}
async function loadMyToken() {
const res = await fetch('/api/auth/me', { credentials: 'include' })
if (res.ok) {
const d = await res.json()
myToken.value = d.apiToken ?? ''
}
}
onMounted(() => { loadSettings(); loadMyToken() })
async function save() {
saving.value = true
saved.value = false
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (res.ok) {
const d = await res.json()
hasToken.value = d.hasToken
jellyfinAdminToken.value = '' // vider le champ après sauvegarde
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
saving.value = false
}
async function testConnection() {
testing.value = true
testResult.value = null
const res = await fetch('/api/jellyfin/test', { method: 'POST', credentials: 'include' })
testResult.value = await res.json()
testing.value = false
}
async function syncUsers() {
syncing.value = true
syncError.value = null
syncResult.value = null
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (res.ok) syncResult.value = data
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
syncing.value = false
}
async function copyToken() {
await navigator.clipboard.writeText(myToken.value)
tokenCopied.value = true
setTimeout(() => { tokenCopied.value = false }, 2000)
}
async function regenerateToken() {
if (!confirm('Regénérer le token ? L\'ancien token sera immédiatement invalidé.')) return
const res = await fetch('/api/auth/regenerate-token', {
method: 'POST', credentials: 'include',
})
if (res.ok) {
const d = await res.json()
myToken.value = d.apiToken
}
}
</script>
+174
View File
@@ -0,0 +1,174 @@
<template>
<div class="flex flex-col gap-6">
<!-- ── Informations du compte ────────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Mon compte</h2>
<p class="text-sm text-muted mt-1">Informations associées à votre compte FanKarr.</p>
</div>
<div class="settings-card flex flex-col gap-3">
<div>
<label class="settings-label mb-1">Nom d'utilisateur</label>
<p class="text-sm text-primary font-mono bg-shell px-3 py-2 rounded-lg border border-border w-fit">
{{ auth.username }}
</p>
</div>
<div>
<label class="settings-label mb-1">Rôle</label>
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="auth.isAdmin ? 'bg-violet-500/20 text-violet-300' : 'bg-blue-500/20 text-blue-300'">
{{ auth.isAdmin ? 'Administrateur' : 'Utilisateur' }}
</span>
</div>
</div>
</section>
<!-- ── Changer de mot de passe ───────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Changer le mot de passe</h2>
<p class="text-sm text-muted mt-1">Votre nouveau mot de passe doit faire au moins 6 caractères.</p>
</div>
<div class="settings-card flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Mot de passe actuel</label>
<input v-model="currentPassword" type="password" class="settings-input" placeholder="••••••••" />
</div>
<div>
<label class="settings-label mb-1.5">Nouveau mot de passe</label>
<input v-model="newPassword" type="password" class="settings-input" placeholder="••••••••" />
</div>
<div>
<label class="settings-label mb-1.5">Confirmer le nouveau mot de passe</label>
<input v-model="confirmPassword" type="password" class="settings-input" placeholder="••••••••" />
</div>
<div class="flex items-center gap-3 flex-wrap">
<button @click="changePassword" :disabled="changingPwd" class="btn-primary">
{{ changingPwd ? 'Modification…' : 'Modifier le mot de passe' }}
</button>
<span v-if="pwdSuccess" class="text-xs text-green-400">Mot de passe modifié.</span>
<span v-if="pwdError" class="text-xs text-red-400">{{ pwdError }}</span>
</div>
</div>
</section>
<!-- ── Token API personnel ──────────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Token API personnel</h2>
<p class="text-sm text-muted mt-1">
Utilisez ce token pour accéder à l'API FanKarr depuis des applications tierces
(ex : plugin Jellyfin).
</p>
</div>
<div class="settings-card flex flex-col gap-3">
<div class="flex items-center gap-2">
<code class="flex-1 text-xs bg-shell px-3 py-2 rounded-lg border border-border text-muted font-mono truncate">
{{ showToken ? myToken : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' }}
</code>
<button @click="showToken = !showToken" class="btn-ghost text-xs shrink-0">
{{ showToken ? 'Masquer' : 'Afficher' }}
</button>
<button @click="copyToken" class="btn-ghost text-xs shrink-0">
{{ tokenCopied ? 'Copié !' : 'Copier' }}
</button>
</div>
<button @click="regenerateToken" class="btn-ghost text-xs text-red-400 hover:text-red-300 w-fit">
Regénérer le token
</button>
<p class="text-xs text-muted">
Authentification via header :
<code class="bg-shell px-1.5 py-0.5 rounded text-xs">Authorization: Bearer &lt;token&gt;</code>
</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
// ── Mot de passe ──────────────────────────────────────────────
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const changingPwd = ref(false)
const pwdSuccess = ref(false)
const pwdError = ref<string | null>(null)
async function changePassword() {
pwdError.value = null
pwdSuccess.value = false
if (!currentPassword.value || !newPassword.value) {
pwdError.value = 'Tous les champs sont requis.'; return
}
if (newPassword.value !== confirmPassword.value) {
pwdError.value = 'Les mots de passe ne correspondent pas.'; return
}
if (newPassword.value.length < 6) {
pwdError.value = 'Le mot de passe doit faire au moins 6 caractères.'; return
}
changingPwd.value = true
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ currentPassword: currentPassword.value, newPassword: newPassword.value }),
})
const data = await res.json()
if (res.ok) {
pwdSuccess.value = true
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
setTimeout(() => { pwdSuccess.value = false }, 3000)
} else {
pwdError.value = data.error ?? 'Erreur lors du changement de mot de passe'
}
changingPwd.value = false
}
// ── Token API ─────────────────────────────────────────────────
const myToken = ref('')
const showToken = ref(false)
const tokenCopied = ref(false)
async function loadMyToken() {
const res = await fetch('/api/auth/me', { credentials: 'include' })
if (res.ok) {
const d = await res.json()
myToken.value = d.apiToken ?? ''
}
}
onMounted(loadMyToken)
async function copyToken() {
await navigator.clipboard.writeText(myToken.value)
tokenCopied.value = true
setTimeout(() => { tokenCopied.value = false }, 2000)
}
async function regenerateToken() {
if (!confirm('Regénérer le token ? L\'ancien token sera immédiatement invalidé.')) return
const res = await fetch('/api/auth/regenerate-token', {
method: 'POST', credentials: 'include',
})
if (res.ok) {
const d = await res.json()
myToken.value = d.apiToken
}
}
</script>
+3
View File
@@ -14,10 +14,13 @@ import { useRoute } from 'vue-router'
const route = useRoute()
const titles: Record<string, string> = {
'settings-profile' : 'Mon profil',
'settings-download' : 'Clients de téléchargement',
'settings-media' : 'Gestion des médias',
'settings-catalogue' : 'Catalogue Fankai',
'settings-logs' : 'Journaux',
'settings-users' : 'Utilisateurs & Invitations',
'settings-jellyfin' : 'Jellyfin & API',
}
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
+325
View File
@@ -0,0 +1,325 @@
<template>
<div class="flex flex-col gap-8">
<!-- ── Utilisateurs ─────────────────────────────────────── -->
<section class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Utilisateurs</h2>
<p class="text-sm text-muted mt-1">Gérez les comptes ayant accès à FanKarr.</p>
</div>
<button @click="openCreateUser" class="btn-secondary">+ Créer</button>
</div>
<div v-if="users.length === 0" class="settings-card text-sm text-muted">
Aucun utilisateur.
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="u in users" :key="u.id"
class="settings-card flex items-center justify-between"
>
<div class="flex items-center gap-3">
<span
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
>
{{ u.username[0].toUpperCase() }}
</span>
<div>
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
<p class="text-xs text-muted mt-0.5">
{{ u.role === 'admin' ? 'Administrateur' : 'Utilisateur' }}
· créé le {{ formatDate(u.createdAt) }}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<button @click="openEditUser(u)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
<button
@click="confirmDelete(u)"
:disabled="u.id === currentUserId"
class="btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
>Supprimer</button>
</div>
</div>
</div>
</section>
<!-- ── Invitations ──────────────────────────────────────── -->
<section class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Invitations</h2>
<p class="text-sm text-muted mt-1">Générez des liens pour permettre à de nouveaux utilisateurs de s'inscrire.</p>
</div>
<button @click="openCreateInvite" class="btn-secondary">+ Générer</button>
</div>
<div v-if="invites.length === 0" class="settings-card text-sm text-muted">
Aucun lien d'invitation actif.
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="inv in invites" :key="inv.code"
class="settings-card flex items-center justify-between gap-4"
:class="{ 'opacity-50': isExpired(inv) || isExhausted(inv) }"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span v-if="isExpired(inv) || isExhausted(inv)" class="text-xs text-muted">[Invalide]</span>
<span v-if="inv.note" class="text-sm text-primary font-medium truncate">{{ inv.note }}</span>
<span v-else class="text-sm text-muted italic">Sans note</span>
</div>
<p class="text-xs text-muted">
{{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisations
<template v-if="inv.expiresAt"> · expire {{ formatDate(inv.expiresAt) }}</template>
<template v-else> · sans expiration</template>
</p>
<div class="flex items-center gap-2 mt-1.5">
<code class="text-xs text-muted bg-shell px-2 py-0.5 rounded truncate max-w-xs">
{{ inviteUrl(inv.code) }}
</code>
<button @click="copyInvite(inv.code)" class="btn-ghost text-xs shrink-0">
{{ copied === inv.code ? 'Copié !' : 'Copier' }}
</button>
</div>
</div>
<button @click="deleteInvite(inv.code)" class="btn-ghost text-xs text-red-400 hover:text-red-300 shrink-0">
Révoquer
</button>
</div>
</div>
</section>
<!-- ── Modal créer/modifier utilisateur ─────────────────── -->
<Teleport to="body">
<div v-if="userModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="userModal = null">
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
<h3 class="text-base font-semibold text-primary">
{{ userModal.mode === 'create' ? 'Créer un utilisateur' : 'Modifier l\'utilisateur' }}
</h3>
<div class="flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Identifiant</label>
<input v-model="userModal.username" type="text" class="settings-input" placeholder="username" />
</div>
<div>
<label class="settings-label mb-1.5">
{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}
</label>
<input v-model="userModal.password" type="password" class="settings-input"
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
</div>
<div>
<label class="settings-label mb-1.5">Rôle</label>
<select v-model="userModal.role" class="settings-input">
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
</div>
<p v-if="userModal.error" class="text-xs text-red-400">{{ userModal.error }}</p>
<div class="flex gap-2 justify-end">
<button @click="userModal = null" class="btn-ghost">Annuler</button>
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
{{ userModal.loading ? '...' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ── Modal créer invitation ────────────────────────────── -->
<Teleport to="body">
<div v-if="inviteModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="inviteModal = null">
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
<h3 class="text-base font-semibold text-primary">Générer une invitation</h3>
<div class="flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Note (optionnelle)</label>
<input v-model="inviteModal.note" type="text" class="settings-input" placeholder="Pour qui est ce lien ?" />
</div>
<div>
<label class="settings-label mb-1.5">Utilisations max</label>
<input v-model.number="inviteModal.maxUses" type="number" min="1" class="settings-input" placeholder="1" />
<p class="text-xs text-muted mt-1">Laisser vide pour illimité</p>
</div>
<div>
<label class="settings-label mb-1.5">Expire dans (heures)</label>
<input v-model.number="inviteModal.expiresInHours" type="number" min="1" class="settings-input" placeholder="24" />
<p class="text-xs text-muted mt-1">Laisser vide pour pas d'expiration</p>
</div>
</div>
<p v-if="inviteModal.error" class="text-xs text-red-400">{{ inviteModal.error }}</p>
<div class="flex gap-2 justify-end">
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
{{ inviteModal.loading ? '...' : 'Générer' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
interface User {
id : string
username : string
role : 'admin' | 'user'
apiToken : string
createdAt: string
}
interface Invite {
code : string
createdBy : string
createdAt : string
expiresAt : string | null
maxUses : number | null
uses : number
note? : string
}
const auth = useAuthStore()
const currentUserId = auth.username // utilisé pour désactiver la suppression de soi-même
const users = ref<User[]>([])
const invites = ref<Invite[]>([])
const copied = ref<string | null>(null)
// ── Modals ────────────────────────────────────────────────────
const userModal = ref<{
mode : 'create' | 'edit'
id? : string
username: string
password: string
role : 'admin' | 'user'
error : string | null
loading : boolean
} | null>(null)
const inviteModal = ref<{
note? : string
maxUses? : number
expiresInHours?: number
error : string | null
loading : boolean
} | null>(null)
// ── Chargement ────────────────────────────────────────────────
async function loadUsers() {
const res = await fetch('/api/users', { credentials: 'include' })
if (res.ok) users.value = await res.json()
}
async function loadInvites() {
const res = await fetch('/api/invites', { credentials: 'include' })
if (res.ok) invites.value = await res.json()
}
onMounted(() => { loadUsers(); loadInvites() })
// ── Users ─────────────────────────────────────────────────────
function openCreateUser() {
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false }
}
function openEditUser(u: User) {
userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, error: null, loading: false }
}
async function submitUserModal() {
if (!userModal.value) return
const m = userModal.value
m.error = null
m.loading = true
if (!m.username) { m.error = 'Identifiant requis'; m.loading = false; return }
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis'; m.loading = false; return }
const url = m.mode === 'create' ? '/api/users' : `/api/users/${m.id}`
const method = m.mode === 'create' ? 'POST' : 'PATCH'
const body: Record<string, any> = { username: m.username, role: m.role }
if (m.password) body.password = m.password
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
if (!res.ok) { m.error = data.error; m.loading = false; return }
userModal.value = null
loadUsers()
}
async function confirmDelete(u: User) {
if (!confirm(`Supprimer l'utilisateur "${u.username}" ?`)) return
await fetch(`/api/users/${u.id}`, { method: 'DELETE', credentials: 'include' })
loadUsers()
}
// ── Invites ───────────────────────────────────────────────────
function openCreateInvite() {
inviteModal.value = { note: '', maxUses: 1, expiresInHours: 24, error: null, loading: false }
}
async function submitInviteModal() {
if (!inviteModal.value) return
const m = inviteModal.value
m.error = null; m.loading = true
const body: Record<string, any> = {}
if (m.note) body.note = m.note
if (m.maxUses) body.maxUses = m.maxUses
if (m.expiresInHours) body.expiresInHours = m.expiresInHours
const res = await fetch('/api/invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
if (!res.ok) { m.error = data.error; m.loading = false; return }
inviteModal.value = null
loadInvites()
}
async function deleteInvite(code: string) {
if (!confirm('Révoquer ce lien d\'invitation ?')) return
await fetch(`/api/invites/${code}`, { method: 'DELETE', credentials: 'include' })
loadInvites()
}
function inviteUrl(code: string) {
return `${window.location.origin}/invite/${code}`
}
async function copyInvite(code: string) {
await navigator.clipboard.writeText(inviteUrl(code))
copied.value = code
setTimeout(() => { copied.value = null }, 2000)
}
// ── Helpers ───────────────────────────────────────────────────
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function isExpired(inv: Invite) {
return inv.expiresAt !== null && new Date(inv.expiresAt) < new Date()
}
function isExhausted(inv: Invite) {
return inv.maxUses !== null && inv.uses >= inv.maxUses
}
</script>