diff --git a/.env.example b/.env.example index 2e01569..5b3cdaa 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,13 @@ # Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration # AUTH_TOKEN_EXPIRY=30d +# Authentification par proxy (oauth2-proxy, Authelia…) : en-tête contenant le +# nom d'utilisateur, lu uniquement pour les adresses de PROXY_AUTH_TRUSTED_PROXIES +# PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username +# PROXY_AUTH_TRUSTED_PROXIES=127.0.0.1 +# PROXY_AUTH_AUTO_CREATE=false +# PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out + # ── Serveur ─────────────────────────────────────────────────────────────────── # Port d'écoute (défaut : 9898) diff --git a/README.md b/README.md index 7565f6b..201d259 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,10 @@ FanKarr est alors accessible sur `http://localhost:9898`. La configuration, les | `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) | | `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie | | `BASE_URL` | non défini | Sous-dossier derrière un reverse proxy (ex. `/fankarr`) | +| `PROXY_AUTH_HEADER` | non défini | En-tête contenant le nom d'utilisateur authentifié par le proxy (ex. `Remote-User`) | +| `PROXY_AUTH_TRUSTED_PROXIES` | non défini | Adresses autorisées à envoyer cet en-tête (IP ou plages IPv4 CIDR, séparées par des virgules) | +| `PROXY_AUTH_AUTO_CREATE` | `false` | Crée un compte utilisateur à la première connexion d'un nom inconnu | +| `PROXY_AUTH_LOGOUT_URL` | non défini | Page de déconnexion du proxy, ouverte par « Se déconnecter » (ex. `/oauth2/sign_out`) | Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`. @@ -233,6 +237,49 @@ Laissez `BASE_URL` vide pour un sous-domaine dédié (`https://fankarr.exemple.t --- +### Authentification par proxy (oauth2-proxy, Authelia…) + +Si un proxy d'authentification (oauth2-proxy devant Keycloak, Authelia, +Authentik…) contrôle déjà l'accès à FanKarr, FanKarr peut ouvrir la session +directement à partir du nom d'utilisateur qu'il transmet, sans second écran de +connexion : + +```yaml +environment: + - PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username # en-tête posé par le proxy + - PROXY_AUTH_TRUSTED_PROXIES=172.18.0.0/16 # adresse(s) du reverse proxy + - PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out # facultatif + # - PROXY_AUTH_AUTO_CREATE=true # facultatif +``` + +- Le nom transmis est rapproché d'un compte FanKarr existant, sans tenir compte + de la casse. Un nom inconnu est refusé (avertissement dans les journaux), sauf + avec `PROXY_AUTH_AUTO_CREATE=true` : un compte **utilisateur** sans mot de + passe est alors créé. Les rôles se gèrent dans Paramètres › Utilisateurs. +- L'en-tête n'est lu que pour les connexions venant de + `PROXY_AUTH_TRUSTED_PROXIES` : sans cette variable, la fonction reste + désactivée. Ne publiez pas le port de FanKarr au-delà du proxy et des + applications qui utilisent l'API. +- Le premier compte administrateur se crée toujours avec l'assistant. +- La connexion par mot de passe et par Jellyfin reste possible, ainsi que les + jetons d'API. + +Exemple avec NPMplus et oauth2-proxy : choisissez `oauth2proxy` dans +« Auth Request » de la location FanKarr. L'en-tête `X-User` que NPMplus +transmet contient l'identifiant interne de l'utilisateur (`sub`, un UUID avec +Keycloak) et non son nom de connexion. Transmettez plutôt ce dernier en ajoutant +dans la configuration avancée de la location : + +```nginx +auth_request_set $preferred_username $upstream_http_x_auth_request_preferred_username; +proxy_set_header X-Forwarded-Preferred-Username $preferred_username; +``` + +puis `PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username`. oauth2-proxy doit +tourner avec `--set-xauthrequest`. + +--- + ### Premier lancement 1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur). diff --git a/server/auth.ts b/server/auth.ts index 5229eb9..fdea164 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -15,6 +15,7 @@ import { readSettings, type Settings } from './settings.js' import { startOnboarding } from './onboarding.js' import { isJellyfinConfigured, authenticateJellyfinUser, getJellyfinServerId } from './lib/jellyfin.js' import { loginRetryAfter, recordLoginFailure, clearLoginFailures } from './lib/login-throttle.js' +import { proxyAuthUser, PROXY_AUTH_ENABLED, PROXY_AUTH_LOGOUT_URL } from './lib/proxy-auth.js' // ── Typage Express étendu ───────────────────────────────────── declare global { @@ -39,13 +40,39 @@ function signToken(user: User): string { : jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any }) } -function setCookieAndRespond(res: Response, user: User): void { +function setSessionCookie(res: Response, user: User): string { user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt const token = signToken(user) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax', path: COOKIE_PATH }) + return token +} + +function setCookieAndRespond(res: Response, user: User): void { + setSessionCookie(res, user) res.json({ success: true, user: safeUser(user) }) } +// Identifiant du compte de la session, ou undefined si le cookie est absent ou invalide +function sessionUserId(req: Request): string | undefined { + const token = req.cookies?.fankarr_token + if (!token) return undefined + try { return (jwt.verify(token, JWT_SECRET) as any).id } catch { return undefined } +} + +// ── Authentification par proxy ──────────────────────────────── +// Placé avant les routes : quand le proxy désigne un compte, la session FanKarr +// est ouverte (ou remplacée si elle appartient à un autre compte), et la suite +// du traitement voit un cookie valide comme après une connexion classique. +export function proxyAuth(req: Request, res: Response, next: NextFunction): void { + const user = proxyAuthUser(req) + if (user && sessionUserId(req) !== user.id) { + const token = setSessionCookie(res, user) + req.cookies = { ...req.cookies, fankarr_token: token } + logger.info('auth', `Connexion de « ${user.username} » via le proxy d'authentification (rôle : ${user.role})`) + } + next() +} + // ── Routes ──────────────────────────────────────────────────── function jellyfinLoginEnabled(s: Settings): boolean { @@ -57,21 +84,22 @@ export function authStatus(req: Request, res: Response): void { const settings = readSettings() const setup = hasUsers() const jellyfinLogin = jellyfinLoginEnabled(settings) + const proxy = { proxyAuth: PROXY_AUTH_ENABLED, logoutUrl: PROXY_AUTH_LOGOUT_URL || null } const token = req.cookies?.fankarr_token - if (!token) { res.json({ setup, loggedIn: false, jellyfinLogin }); return } + if (!token) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return } try { const payload = jwt.verify(token, JWT_SECRET) as any const user = findById(payload.id) - if (!user) { res.json({ setup, loggedIn: false, jellyfinLogin }); return } + if (!user) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return } res.json({ - setup, loggedIn: true, jellyfinLogin, role: user.role, username: user.username, userId: user.id, + setup, loggedIn: true, jellyfinLogin, ...proxy, role: user.role, username: user.username, userId: user.id, onboardingDone: !!settings.onboardingCompletedAt, tourSeen : !!user.tourSeenAt, }) } catch { - res.json({ setup, loggedIn: false, jellyfinLogin }) + res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }) } } diff --git a/server/index.ts b/server/index.ts index 6083b68..9327232 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,7 +3,7 @@ import cookieParser from 'cookie-parser' import http from 'http' import fs from 'fs' import path from 'path' -import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin } from './auth.js' +import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin, proxyAuth } from './auth.js' import { migrateIfNeeded } from './users.js' import { migrateOnboarding } from './onboarding.js' import { readSettings } from './settings.js' @@ -80,6 +80,8 @@ migrateIfNeeded() migrateOnboarding() // ── Authentification ─────────────────────────────────────────── +// Session ouverte d'après l'en-tête du proxy d'authentification (PROXY_AUTH_HEADER) +site.use(proxyAuth) site.get ('/api/auth/status', authStatus) site.post('/api/auth/setup', authSetup) site.post('/api/auth/login', authLogin) diff --git a/server/lib/proxy-auth.test.ts b/server/lib/proxy-auth.test.ts new file mode 100644 index 0000000..eaff447 --- /dev/null +++ b/server/lib/proxy-auth.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Request } from 'express' + +const users = vi.hoisted(() => ({ + list: [] as { id: string; username: string; role: string }[], +})) + +vi.mock('../logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('../users.js', () => ({ + hasUsers : () => users.list.length > 0, + findByUsername: (name: string) => users.list.find(u => u.username.toLowerCase() === name.toLowerCase()), + createUser : (username: string, _password: string, role: string) => { + const user = { id: `id-${username}`, username, role } + users.list.push(user) + return user + }, +})) + +const ENV_KEYS = ['PROXY_AUTH_HEADER', 'PROXY_AUTH_TRUSTED_PROXIES', 'PROXY_AUTH_AUTO_CREATE', 'PROXY_AUTH_LOGOUT_URL'] + +async function load(env: Record) { + for (const key of ENV_KEYS) delete process.env[key] + Object.assign(process.env, env) + vi.resetModules() + return import('./proxy-auth.js') +} + +function request(remoteAddress: string, headers: Record = {}): Request { + return { socket: { remoteAddress }, headers } as unknown as Request +} + +beforeEach(() => { + users.list = [{ id: 'id-alice', username: 'Alice', role: 'admin' }] +}) + +afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key] +}) + +describe('adresses de confiance', () => { + it('reconnaît une plage IPv4, une adresse exacte et une IPv4 vue par un socket IPv6', async () => { + const { parseTrustedProxies, isTrustedAddress } = await load({}) + const trusted = parseTrustedProxies('172.18.0.0/16, 10.0.0.5, fd00::1') + + expect(isTrustedAddress('172.18.3.4', trusted)).toBe(true) + expect(isTrustedAddress('::ffff:172.18.0.2', trusted)).toBe(true) + expect(isTrustedAddress('10.0.0.5', trusted)).toBe(true) + expect(isTrustedAddress('FD00::1', trusted)).toBe(true) + + expect(isTrustedAddress('172.19.0.1', trusted)).toBe(false) + expect(isTrustedAddress('10.0.0.6', trusted)).toBe(false) + expect(isTrustedAddress(undefined, trusted)).toBe(false) + }) + + it('ignore les entrées invalides', async () => { + const { parseTrustedProxies } = await load({}) + expect(parseTrustedProxies('pas-une-ip, 10.0.0.0/40, fd00::/64')).toHaveLength(0) + }) +}) + +describe('proxyAuthUser', () => { + const enabled = { PROXY_AUTH_HEADER: 'X-User', PROXY_AUTH_TRUSTED_PROXIES: '172.18.0.0/16' } + + it('est désactivé sans adresse de confiance, même si l\'en-tête est défini', async () => { + const mod = await load({ PROXY_AUTH_HEADER: 'X-User' }) + expect(mod.PROXY_AUTH_ENABLED).toBe(false) + expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))).toBeUndefined() + }) + + it('renvoie le compte désigné par un proxy de confiance, sans tenir compte de la casse', async () => { + const mod = await load(enabled) + expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))?.id).toBe('id-alice') + }) + + it('ignore l\'en-tête venant d\'une adresse non déclarée', async () => { + const mod = await load(enabled) + expect(mod.proxyAuthUser(request('192.168.1.50', { 'x-user': 'alice' }))).toBeUndefined() + }) + + it('ne fait rien sans en-tête', async () => { + const mod = await load(enabled) + expect(mod.proxyAuthUser(request('172.18.0.2'))).toBeUndefined() + }) + + it('ne crée pas de compte inconnu par défaut', async () => { + const mod = await load(enabled) + expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined() + expect(users.list).toHaveLength(1) + }) + + it('crée un compte utilisateur avec PROXY_AUTH_AUTO_CREATE', async () => { + const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' }) + const user = mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' })) + expect(user).toMatchObject({ username: 'bob', role: 'user' }) + }) + + it('laisse l\'assistant créer le premier compte', async () => { + users.list = [] + const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' }) + expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined() + expect(users.list).toHaveLength(0) + }) + + it('n\'expose l\'adresse de déconnexion que si l\'authentification par proxy est active', async () => { + expect((await load({ PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('') + expect((await load({ ...enabled, PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('/oauth2/sign_out') + }) +}) diff --git a/server/lib/proxy-auth.ts b/server/lib/proxy-auth.ts new file mode 100644 index 0000000..7b01142 --- /dev/null +++ b/server/lib/proxy-auth.ts @@ -0,0 +1,118 @@ +import net from 'net' +import type { Request } from 'express' +import { logger } from '../logger.js' +import { findByUsername, createUser, hasUsers, type User } from '../users.js' + +// ── Authentification déléguée à un proxy (oauth2-proxy, Authelia…) ── +// Le proxy authentifie l'utilisateur et transmet son nom dans un en-tête. +// L'en-tête n'est lu que pour les connexions venant d'une adresse déclarée +// dans PROXY_AUTH_TRUSTED_PROXIES : sans cela, n'importe quel client joignant +// directement le port de FanKarr pourrait se faire passer pour un autre. + +function parseBool(raw: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes((raw ?? '').trim().toLowerCase()) +} + +type Matcher = (ip: string) => boolean + +function ipv4ToInt(ip: string): number { + return ip.split('.').reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0 +} + +// '::ffff:172.18.0.5' (IPv4 vue par un socket IPv6) → '172.18.0.5' +export function normalizeIp(ip: string): string { + const trimmed = ip.trim() + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(trimmed) + return mapped ? mapped[1] : trimmed.toLowerCase() +} + +// Entrée acceptée : adresse IPv4/IPv6 exacte ou plage IPv4 en notation CIDR +function parseTrustedEntry(entry: string): Matcher | null { + const [addr, bitsRaw] = entry.split('/') + const ip = normalizeIp(addr) + if (bitsRaw === undefined) { + return net.isIP(ip) ? (candidate) => candidate === ip : null + } + const bits = Number(bitsRaw) + if (net.isIPv4(ip) && Number.isInteger(bits) && bits >= 0 && bits <= 32) { + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0 + const base = ipv4ToInt(ip) & mask + return (candidate) => net.isIPv4(candidate) && (ipv4ToInt(candidate) & mask) === base + } + return null +} + +export function parseTrustedProxies(raw: string | undefined): Matcher[] { + const matchers: Matcher[] = [] + for (const entry of (raw ?? '').split(',').map(e => e.trim()).filter(Boolean)) { + const matcher = parseTrustedEntry(entry) + if (matcher) matchers.push(matcher) + else logger.warn('auth', `PROXY_AUTH_TRUSTED_PROXIES : « ${entry} » ignoré (attendu : adresse IP ou plage IPv4 CIDR)`) + } + return matchers +} + +export function isTrustedAddress(remoteAddress: string | undefined, matchers: Matcher[]): boolean { + if (!remoteAddress) return false + const ip = normalizeIp(remoteAddress) + return matchers.some(match => match(ip)) +} + +const HEADER = (process.env.PROXY_AUTH_HEADER ?? '').trim().toLowerCase() +const TRUSTED = parseTrustedProxies(process.env.PROXY_AUTH_TRUSTED_PROXIES) +const AUTO_CREATE = parseBool(process.env.PROXY_AUTH_AUTO_CREATE) +const LOGOUT_URL = (process.env.PROXY_AUTH_LOGOUT_URL ?? '').trim() + +export const PROXY_AUTH_ENABLED = (() => { + if (!HEADER) return false + if (TRUSTED.length === 0) { + logger.error('auth', 'PROXY_AUTH_HEADER est défini mais PROXY_AUTH_TRUSTED_PROXIES est vide : authentification par proxy désactivée') + return false + } + logger.info('auth', `Authentification par proxy activée (en-tête ${HEADER}${AUTO_CREATE ? ', création automatique des comptes' : ''})`) + return true +})() + +export const PROXY_AUTH_LOGOUT_URL = PROXY_AUTH_ENABLED ? LOGOUT_URL : '' + +// Avertissements déjà émis, pour ne pas les répéter à chaque requête +const warned = new Set() +function warnOnce(key: string, message: string): void { + if (warned.has(key) || warned.size > 1_000) return + warned.add(key) + logger.warn('auth', message) +} + +// Compte FanKarr désigné par le proxy, ou undefined si la requête n'en vient pas +export function proxyAuthUser(req: Request): User | undefined { + if (!PROXY_AUTH_ENABLED) return undefined + + const raw = req.headers[HEADER] + const username = (Array.isArray(raw) ? raw[0] : raw)?.trim() + if (!username) return undefined + + // Adresse du socket et non req.ip : X-Forwarded-For est fourni par le client + if (!isTrustedAddress(req.socket.remoteAddress, TRUSTED)) { + warnOnce(`ip|${req.socket.remoteAddress}`, `En-tête ${HEADER} ignoré : la connexion vient de ${req.socket.remoteAddress}, absent de PROXY_AUTH_TRUSTED_PROXIES`) + return undefined + } + + // Premier lancement : le compte admin est créé par l'assistant + if (!hasUsers()) return undefined + + const user = findByUsername(username) + if (user) return user + + if (!AUTO_CREATE) { + warnOnce(`user|${username.toLowerCase()}`, `Utilisateur « ${username} » transmis par le proxy inconnu de FanKarr : créez le compte ou activez PROXY_AUTH_AUTO_CREATE`) + return undefined + } + + try { + // Sans mot de passe FanKarr, comme un compte importé de Jellyfin : la connexion passe par le proxy + return createUser(username, '', 'user') + } catch (err) { + logger.error('auth', `Création du compte « ${username} » transmis par le proxy impossible : ${err instanceof Error ? err.message : err}`) + return undefined + } +} diff --git a/server/users.ts b/server/users.ts index 37f9caa..b37aa51 100644 --- a/server/users.ts +++ b/server/users.ts @@ -165,7 +165,11 @@ export function changePassword(id: string, currentPassword: string, newPassword: const users = readUsers() const user = users.find(u => u.id === id) if (!user) throw new Error('Utilisateur introuvable') - if (!user.passwordHash) throw new Error('Ce compte se connecte avec Jellyfin : il n\'a pas de mot de passe FanKarr') + if (!user.passwordHash) { + throw new Error(user.jellyfinId + ? 'Ce compte se connecte avec Jellyfin : il n\'a pas de mot de passe FanKarr' + : 'Ce compte n\'a pas de mot de passe FanKarr') + } if (!bcrypt.compareSync(currentPassword, user.passwordHash)) { throw new Error('Mot de passe actuel incorrect') } diff --git a/src/components/nav/MobileHeader.vue b/src/components/nav/MobileHeader.vue index dee93e0..704b666 100644 --- a/src/components/nav/MobileHeader.vue +++ b/src/components/nav/MobileHeader.vue @@ -100,7 +100,7 @@ function openSetup() { async function logout() { open.value = false - await auth.logout() + if (await auth.logout()) return router.push('/auth') } diff --git a/src/components/nav/SidebarNav.vue b/src/components/nav/SidebarNav.vue index c903345..d86c55f 100644 --- a/src/components/nav/SidebarNav.vue +++ b/src/components/nav/SidebarNav.vue @@ -112,7 +112,7 @@ function openSetup() { const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck() async function logout() { - await auth.logout() + if (await auth.logout()) return router.push('/auth') } diff --git a/src/stores/auth.ts b/src/stores/auth.ts index b615416..47de8f0 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -12,6 +12,8 @@ export const useAuthStore = defineStore('auth', () => { const onboardingDone = ref(true) const tourSeen = ref(true) const jellyfinLogin = ref(false) + const proxyAuth = ref(false) + const logoutUrl = ref(null) const isAdmin = computed(() => role.value === 'admin') @@ -27,6 +29,8 @@ export const useAuthStore = defineStore('auth', () => { onboardingDone.value = data.onboardingDone ?? true tourSeen.value = data.tourSeen ?? true jellyfinLogin.value = data.jellyfinLogin ?? false + proxyAuth.value = data.proxyAuth ?? false + logoutUrl.value = data.logoutUrl ?? null } catch { loggedIn.value = false } finally { @@ -68,14 +72,21 @@ export const useAuthStore = defineStore('auth', () => { return data.error ?? 'Impossible de créer le compte.' } - async function logout() { + // true : la page quitte FanKarr pour la déconnexion du proxy d'authentification, + // sans quoi le proxy rouvrirait la session à la requête suivante + async function logout(): Promise { await fetch('/api/auth/logout', { method: 'POST' }) + if (logoutUrl.value) { + window.location.href = logoutUrl.value + return true + } loggedIn.value = false role.value = null username.value = null userId.value = null onboardingDone.value = true tourSeen.value = true + return false } async function markTourSeen() { @@ -96,7 +107,7 @@ export const useAuthStore = defineStore('auth', () => { return { loggedIn, setup, loading, role, username, userId, isAdmin, - onboardingDone, tourSeen, jellyfinLogin, + onboardingDone, tourSeen, jellyfinLogin, proxyAuth, logoutUrl, checkStatus, login, setupAccount, logout, markTourSeen, completeOnboarding, } }) diff --git a/src/views/settings/ProfileView.vue b/src/views/settings/ProfileView.vue index 06b5f00..e67aa0b 100644 --- a/src/views/settings/ProfileView.vue +++ b/src/views/settings/ProfileView.vue @@ -23,7 +23,9 @@ @@ -97,7 +99,7 @@ import SettingsSection from '@/components/settings/SettingsSection.vue const auth = useAuthStore() -const me = ref<{ createdAt?: string; lastLoginAt?: string | null; hasPassword?: boolean } | null>(null) +const me = ref<{ createdAt?: string; lastLoginAt?: string | null; hasPassword?: boolean; jellyfin?: boolean } | null>(null) function formatDate(iso: string) { return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) @@ -159,7 +161,7 @@ async function loadMe() { if (res.ok) { const d = await res.json() myToken.value = d.apiToken ?? '' - me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt, hasPassword: d.hasPassword } + me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt, hasPassword: d.hasPassword, jellyfin: !!d.jellyfinId } } } diff --git a/src/views/settings/UsersView.vue b/src/views/settings/UsersView.vue index 8ae9554..7a4c494 100644 --- a/src/views/settings/UsersView.vue +++ b/src/views/settings/UsersView.vue @@ -164,7 +164,7 @@
+ :placeholder="userModal.mode === 'create' ? '••••••••' : userModal.hasPassword ? 'Laissez vide pour le conserver' : userModal.jellyfin ? 'Aucun mot de passe FanKarr (connexion Jellyfin)' : 'Aucun mot de passe FanKarr'" />
Rôle @@ -279,6 +279,7 @@ const userModal = ref<{ password: string role : 'admin' | 'user' hasPassword: boolean + jellyfin : boolean error : string | null loading : boolean } | null>(null) @@ -362,11 +363,11 @@ onUnmounted(() => document.removeEventListener('click', closeUserMenu)) // ── Utilisateurs ── function openCreateUser() { - userModal.value = { mode: 'create', username: '', password: '', role: 'user', hasPassword: true, error: null, loading: false } + userModal.value = { mode: 'create', username: '', password: '', role: 'user', hasPassword: true, jellyfin: false, error: null, loading: false } } function openEditUser(u: User) { - userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, hasPassword: u.hasPassword, error: null, loading: false } + userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, hasPassword: u.hasPassword, jellyfin: !!u.jellyfinId, error: null, loading: false } } async function submitUserModal() { diff --git a/tsconfig.server.json b/tsconfig.server.json index 852286e..56d7516 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -11,5 +11,5 @@ "sourceMap" : false }, "include": ["server/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "server/**/*.test.ts"] } \ No newline at end of file