feat(auth): connexion avec un compte Jellyfin (#154)

* fix(jellyfin): vérifier que le jeton Jellyfin appartient à l'utilisateur annoncé

L'échange du plugin (POST /api/v1/auth/jellyfin) validait le jeton avec
GET /Users/{jellyfinUserId}, que Jellyfin accepte avec le jeton de
n'importe quel utilisateur. Un utilisateur Jellyfin pouvait ainsi obtenir
le jeton d'API FanKarr d'un autre compte, administrateur compris.

Le jeton est maintenant vérifié avec GET /Users/Me, et son propriétaire
doit correspondre à jellyfinUserId.

* feat(auth): connexion avec un compte Jellyfin

Les utilisateurs Jellyfin se connectent à l'interface web avec leurs
identifiants Jellyfin, vérifiés par Jellyfin (POST /Users/AuthenticateByName).

- Import des utilisateurs Jellyfin au choix depuis Paramètres › Jellyfin et
  API ; l'import automatique horaire devient un réglage (activé par défaut).
  Un compte FanKarr du même nom est lié au lieu d'être recréé.
- Seuls les comptes importés peuvent se connecter avec Jellyfin, sauf si
  « Autoriser les comptes pas encore importés » est activé (désactivé par
  défaut) : le compte invité est alors créé à la première connexion, depuis
  l'interface web ou le plugin.
- Interrupteur « Connexion à FanKarr avec un compte Jellyfin » (activé par
  défaut).
- L'identifiant du serveur Jellyfin est enregistré et vérifié à chaque
  connexion ; la session Jellyfin ouverte pour la vérification est fermée
  aussitôt.
- Connexion limitée à 5 échecs en 15 minutes par nom d'utilisateur et adresse.
- Les comptes importés n'ont pas de mot de passe FanKarr : la page Profil
  l'indique, l'administrateur peut toujours en définir un.
This commit is contained in:
ElPouki authored and GitHub committed 2026-09-21 16:27:04 +02:00
1 parent 8940ced4b1
commit bd8c233e45
17 files changed
+649 -142

No files matched your search

+3
View File
@@ -35,6 +35,9 @@
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? (isSetup ? 'Création…' : 'Connexion…') : isSetup ? 'Créer le compte' : 'Se connecter' }}
</button>
<p v-if="!isSetup && auth.jellyfinLogin" class="text-meta text-muted text-center">
Vous pouvez aussi vous connecter avec votre compte Jellyfin.
</p>
</div>
</div>
+80 -31
View File
@@ -2,31 +2,57 @@
<div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Renseignez d\'abord la clé API Jellyfin.'">
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
<button @click="importOpen = true" :disabled="!configured" class="btn-secondary pointer-fine:h-[38px]" :title="configured ? undefined : NOT_CONFIGURED">
<UserPlus :size="15" /> Importer des utilisateurs
</button>
</Teleport>
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search.">
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à importer les comptes et à connecter leurs utilisateurs à FanKarr, depuis l'interface web ou le plugin FanKarr Search.">
<JellyfinConnectionForm @change="configured = !!$event.jellyfinUrl && $event.hasToken" />
</SettingsSection>
<SettingsSection
title="Synchronisation des utilisateurs"
description="Crée un compte FanKarr pour chaque utilisateur Jellyfin actif (les comptes existants ne sont pas modifiés). Synchronisation automatique toutes les heures."
title="Utilisateurs Jellyfin"
description="Un utilisateur importé reçoit un compte invité. Si un compte FanKarr porte déjà son nom, ce compte est lié au lieu d'être recréé."
>
<template #actions>
<span v-if="savedSection === 'users'" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
</template>
<div class="flex items-center gap-3 flex-wrap">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]">
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
<button @click="importOpen = true" :disabled="!configured" class="btn-secondary pointer-fine:h-[38px]">
Importer des utilisateurs
</button>
<span v-if="!hasToken" class="text-meta text-muted">Renseignez d'abord la clé API Jellyfin.</span>
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
<span v-else-if="syncResult" class="text-meta text-secondary">
<span class="text-ok">{{ plural(syncResult.created, 'compte créé', 'comptes créés') }}</span>
· {{ plural(syncResult.skipped, 'déjà existant ou désactivé', 'déjà existants ou désactivés') }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
</span>
<span v-if="!configured" class="text-meta text-muted">{{ NOT_CONFIGURED }}</span>
</div>
<SettingsToggle
:model-value="options.jellyfinAutoImport"
@update:model-value="save('jellyfinAutoImport', $event, 'users')"
label="Importer automatiquement tous les utilisateurs"
description="Toutes les heures, chaque utilisateur Jellyfin actif sans compte FanKarr est importé."
/>
</SettingsSection>
<SettingsSection title="Connexion avec Jellyfin" description="Jellyfin vérifie le mot de passe : FanKarr ne le conserve pas.">
<template #actions>
<span v-if="savedSection === 'login'" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
</template>
<SettingsToggle
:model-value="options.jellyfinLogin"
@update:model-value="save('jellyfinLogin', $event, 'login')"
label="Connexion à FanKarr avec un compte Jellyfin"
description="Les comptes importés se connectent à l'interface web avec leurs identifiants Jellyfin."
/>
<SettingsToggle
:model-value="options.jellyfinNewUserLogin"
@update:model-value="save('jellyfinNewUserLogin', $event, 'login')"
label="Autoriser les comptes pas encore importés"
description="Un compte invité est créé à la première connexion, depuis l'interface web ou le plugin FanKarr Search."
/>
<p class="text-xs text-muted">
Un mot de passe erroné saisi dans FanKarr compte aussi comme un échec dans Jellyfin. Si le verrouillage après plusieurs échecs est activé pour un utilisateur dans Jellyfin, son compte y est désactivé une fois la limite atteinte.
</p>
</SettingsSection>
<section class="bg-card rounded-card">
@@ -46,20 +72,30 @@
</div>
</section>
<JellyfinImportModal v-if="importOpen" @close="importOpen = false" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import { ref, onMounted } from 'vue'
import { Check, UserPlus } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import JellyfinImportModal from '@/components/settings/JellyfinImportModal.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import { plural } from '@/utils/format'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useToast } from '@/composables/useToast'
const hasToken = ref(false)
const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null)
type Option = 'jellyfinLogin' | 'jellyfinNewUserLogin' | 'jellyfinAutoImport'
const NOT_CONFIGURED = 'Renseignez d\'abord l\'adresse et la clé API Jellyfin.'
const toast = useToast()
const configured = ref(false)
const importOpen = ref(false)
const options = ref<Record<Option, boolean>>({ jellyfinLogin: true, jellyfinNewUserLogin: false, jellyfinAutoImport: true })
const savedSection = ref<'users' | 'login' | null>(null)
let savedTimer: ReturnType<typeof setTimeout> | null = null
const apiBase = `${window.location.origin}/api/v1`
@@ -72,14 +108,27 @@ const apiEndpoints = [
{ method: 'GET', path: '/auth/me', desc: 'Vérifier le jeton utilisé' },
]
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 ?? 'Impossible de synchroniser les utilisateurs Jellyfin.'
syncing.value = false
onMounted(async () => {
const res = await fetch('/api/jellyfin/settings', { credentials: 'include' }).catch(() => null)
if (!res?.ok) return
const d = await res.json()
options.value = { jellyfinLogin: d.jellyfinLogin, jellyfinNewUserLogin: d.jellyfinNewUserLogin, jellyfinAutoImport: d.jellyfinAutoImport }
})
async function save(key: Option, value: boolean, section: 'users' | 'login') {
const previous = options.value[key]
options.value[key] = value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ [key]: value }),
}).catch(() => null)
if (!res?.ok) {
options.value[key] = previous
toast.add('Impossible d\'enregistrer le réglage.', 'error')
return
}
savedSection.value = section
if (savedTimer) clearTimeout(savedTimer)
savedTimer = setTimeout(() => { savedSection.value = null }, 2000)
}
</script>
+9 -3
View File
@@ -20,7 +20,13 @@
</div>
</SettingsSection>
<SettingsSection title="Mot de passe" description="Au moins 6 caractères.">
<SettingsSection
v-if="me?.hasPassword === false"
title="Mot de passe"
description="Vous vous connectez avec votre compte Jellyfin. Le mot de passe se change dans Jellyfin."
/>
<SettingsSection v-else title="Mot de passe" description="Au moins 6 caractères.">
<div class="grid sm:grid-cols-3 gap-4">
<div class="flex flex-col gap-[7px]">
<label for="pwd-current" class="field-label">Mot de passe actuel</label>
@@ -91,7 +97,7 @@ import SettingsSection from '@/components/settings/SettingsSection.vue
const auth = useAuthStore()
const me = ref<{ createdAt?: string; lastLoginAt?: string | null } | null>(null)
const me = ref<{ createdAt?: string; lastLoginAt?: string | null; hasPassword?: boolean } | null>(null)
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
@@ -153,7 +159,7 @@ async function loadMe() {
if (res.ok) {
const d = await res.json()
myToken.value = d.apiToken ?? ''
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt }
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt, hasPassword: d.hasPassword }
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ const pages: Record<string, { title: string; description: string }> = {
'/settings/import-management': { title: 'Gestion des séries', description: 'Renommage, métadonnées NFO et vérification des fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'Sources du catalogue et surveillance des nouveaux épisodes.' },
'/settings/download-client' : { title: 'Clients torrent', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, synchronisation des comptes et API pour les applications tierces.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, import des comptes et API pour les applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Événements du serveur, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic, mode développeur, assistant et visite guidée.' },
}
+7 -3
View File
@@ -164,7 +164,7 @@
<div class="flex flex-col gap-[7px]">
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
:placeholder="userModal.mode === 'edit' ? 'Laissez vide pour le conserver' : '••••••••'" />
:placeholder="userModal.mode === 'create' ? '••••••••' : userModal.hasPassword ? 'Laissez vide pour le conserver' : 'Aucun mot de passe FanKarr (connexion Jellyfin)'" />
</div>
<div class="flex flex-col gap-[7px]">
<span class="field-label">Rôle</span>
@@ -241,6 +241,8 @@ interface User {
apiToken : string
createdAt : string
lastLoginAt?: string | null
jellyfinId? : string | null
hasPassword : boolean
}
interface Invite {
@@ -275,6 +277,7 @@ const userModal = ref<{
username: string
password: string
role : 'admin' | 'user'
hasPassword: boolean
error : string | null
loading : boolean
} | null>(null)
@@ -358,11 +361,11 @@ onUnmounted(() => document.removeEventListener('click', closeUserMenu))
// ── Utilisateurs ──
function openCreateUser() {
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false }
userModal.value = { mode: 'create', username: '', password: '', role: 'user', hasPassword: true, 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 }
userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, hasPassword: u.hasPassword, error: null, loading: false }
}
async function submitUserModal() {
@@ -399,6 +402,7 @@ function userSubtitle(u: User) {
if (u.id === currentUserId.value) parts.push('vous')
if (pending > 0) parts.push(`${plural(pending, 'demande')} en attente`)
if (parts.length === 0) parts.push(`créé le ${formatDate(u.createdAt)}`)
if (u.jellyfinId) parts.push('Jellyfin')
return parts.join(' · ')
}