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:
1 parent
dfb515b0f8
commit
dabd1d429e
25 files changed
+2865
-225
No files matched your search
@@ -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>
|
||||
@@ -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 <token></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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user