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

+135 -75
View File
@@ -1,112 +1,101 @@
import fs from 'fs'
import fs from 'fs'
import path from 'path'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
import {
findByUsername, findById, findByApiToken,
createUser, changePassword, regenerateApiToken, safeUser, hasUsers,
type User,
} from './users.js'
// AUTH_TOKEN_EXPIRY : durée de validité du token JWT
// Exemples : "1d", "7d", "30d", "1y", "never" / "0" = pas d'expiration
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
// ── Typage Express étendu ─────────────────────────────────────
declare global {
namespace Express {
interface Request { user?: User }
}
}
// ── Durée JWT ─────────────────────────────────────────────────
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
function signToken(username: string): string {
function signToken(user: User): string {
const payload = { id: user.id, username: user.username, role: user.role }
return NO_EXPIRY
? jwt.sign({ username }, JWT_SECRET)
: jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
? jwt.sign(payload, JWT_SECRET)
: jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
}
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
interface AuthData {
username : string
passwordHash: string
}
// ── Helpers fichier ───────────────────────────────────────────
function readAuth(): AuthData | null {
try {
if (!fs.existsSync(DATA_PATH)) return null
return JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8'))
} catch { return null }
}
function writeAuth(data: AuthData): void {
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
function setCookieAndRespond(res: Response, user: User): void {
const token = signToken(user)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true, user: safeUser(user) })
}
// ── Routes ────────────────────────────────────────────────────
// GET /api/auth/status
export function authStatus(req: Request, res: Response): void {
const existing = readAuth()
const token = req.cookies?.fankarr_token
const setup = hasUsers()
const token = req.cookies?.fankarr_token
let loggedIn = false
if (token) {
try {
jwt.verify(token, JWT_SECRET)
loggedIn = true
} catch {
logger.debug('auth', 'Token invalide ou expiré')
}
if (!token) { res.json({ setup, loggedIn: false }); return }
try {
const payload = jwt.verify(token, JWT_SECRET) as any
const user = findById(payload.id)
if (!user) { res.json({ setup, loggedIn: false }); return }
res.json({ setup, loggedIn: true, role: user.role, username: user.username, userId: user.id })
} catch {
res.json({ setup, loggedIn: false })
}
res.json({ setup: !!existing, loggedIn })
}
// POST /api/auth/setup
// POST /api/auth/setup (premier lancement uniquement)
export function authSetup(req: Request, res: Response): void {
if (readAuth()) {
if (hasUsers()) {
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
res.status(400).json({ error: 'Un compte existe déjà' })
return
res.status(400).json({ error: 'Un compte existe déjà' }); return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' })
return
res.status(400).json({ error: 'Username et password requis' }); return
}
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash })
logger.info('auth', `Compte créé pour "${username}"`)
const token = signToken(username)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
try {
const user = createUser(username, password, 'admin')
logger.info('auth', `Premier compte admin créé pour "${username}"`)
setCookieAndRespond(res, user)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
}
// POST /api/auth/login
export function authLogin(req: Request, res: Response): void {
const auth = readAuth()
if (!auth) {
res.status(400).json({ error: 'Aucun compte configuré' })
return
if (!hasUsers()) {
res.status(400).json({ error: 'Aucun compte configuré' }); return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' })
return
res.status(400).json({ error: 'Username et password requis' }); return
}
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
const user = findByUsername(username)
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
logger.warn('auth', `Échec de connexion pour "${username}"`)
res.status(401).json({ error: 'Identifiants incorrects' })
return
res.status(401).json({ error: 'Identifiants incorrects' }); return
}
const token = signToken(username)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
logger.info('auth', `Connexion réussie pour "${username}" (expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
res.json({ success: true })
logger.info('auth', `Connexion de "${username}" (rôle: ${user.role}, expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
setCookieAndRespond(res, user)
}
// POST /api/auth/logout
@@ -114,27 +103,98 @@ export function authLogout(req: Request, res: Response): void {
const token = req.cookies?.fankarr_token
if (token) {
try {
const payload = jwt.verify(token, JWT_SECRET) as any
logger.info('auth', `Déconnexion de "${payload.username}"`)
const p = jwt.verify(token, JWT_SECRET) as any
logger.info('auth', `Déconnexion de "${p.username}"`)
} catch {}
}
res.clearCookie('fankarr_token')
res.json({ success: true })
}
// Middleware protection des routes
// GET /api/auth/me
export function authMe(req: Request, res: Response): void {
res.json(safeUser(req.user!))
}
// POST /api/auth/change-password
export function authChangePassword(req: Request, res: Response): void {
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
res.status(400).json({ error: 'Mot de passe actuel et nouveau requis' }); return
}
if (newPassword.length < 6) {
res.status(400).json({ error: 'Le nouveau mot de passe doit faire au moins 6 caractères' }); return
}
try {
changePassword(req.user!.id, currentPassword, newPassword)
res.json({ success: true })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
}
// POST /api/auth/regenerate-token
export function authRegenerateToken(req: Request, res: Response): void {
try {
const token = regenerateApiToken(req.user!.id)
res.json({ apiToken: token })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
}
// POST /api/auth/register (via lien d'invitation)
export function authRegister(req: Request, res: Response): void {
const { username, password, inviteCode } = req.body
if (!username || !password || !inviteCode) {
res.status(400).json({ error: 'Username, password et code d\'invitation requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
}
// La validation du code se fait dans le router invites (middleware)
// Ici on reçoit déjà un username/password validés — la création est déléguée au router
res.status(400).json({ error: 'Utilisez /api/invites/:code/register' })
}
// ── Middlewares ───────────────────────────────────────────────
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token
if (!token) {
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Non authentifié' })
return
res.status(401).json({ error: 'Non authentifié' }); return
}
try {
jwt.verify(token, JWT_SECRET)
const payload = jwt.verify(token, JWT_SECRET) as any
const user = findById(payload.id)
if (!user) {
res.status(401).json({ error: 'Utilisateur introuvable' }); return
}
req.user = user
next()
} catch {
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
res.status(401).json({ error: 'Token invalide' })
}
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
requireAuth(req, res, () => {
if (req.user?.role !== 'admin') {
logger.warn('auth', `Accès admin refusé pour "${req.user?.username}" (${req.method} ${req.path})`)
res.status(403).json({ error: 'Accès réservé aux administrateurs' }); return
}
next()
})
}
/** Accepte aussi l'authentification par token API (header Authorization: Bearer <token>) */
export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization
if (authHeader?.startsWith('Bearer ')) {
const user = findByApiToken(authHeader.slice(7))
if (user) { req.user = user; next(); return }
}
requireAuth(req, res, next)
}
+39 -17
View File
@@ -3,7 +3,8 @@ import cookieParser from 'cookie-parser'
import http from 'http'
import fs from 'fs'
import path from 'path'
import { authStatus, authSetup, authLogin, authLogout } from './auth.js'
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, requireAuth, requireAdmin } from './auth.js'
import { migrateIfNeeded } from './users.js'
import { readSettings } from './settings.js'
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
@@ -20,6 +21,11 @@ import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/gi
import { pushNotif } from './lib/notifs.js'
import { checkNfoUpdates } from './lib/nfo.js'
import usersRouter from './routes/users.js'
import invitesRouter from './routes/invites.js'
import requestsRouter from './routes/requests.js'
import jellyfinRouter from './routes/jellyfin.js'
import publicApiRouter from './routes/public-api.js'
import settingsRouter from './routes/settings.js'
import torrentClientsRouter from './routes/torrent-clients.js'
import seriesRouter from './routes/series.js'
@@ -51,23 +57,39 @@ if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
}
// ── Auth ───────────────────────────────────────────────────────
app.get('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout)
// ── Migration mono-user → multi-user ──────────────────────────
migrateIfNeeded()
// ── Routes ─────────────────────────────────────────────────────
app.use('/api', settingsRouter)
app.use('/api', torrentClientsRouter)
app.use('/api', seriesRouter)
app.use('/api', downloadsRouter)
app.use('/api', organizeRouter)
app.use('/api', importRouter)
app.use('/api', systemRouter)
app.use('/api', nfoUpdatesRouter)
app.use('/api', plexRouter)
app.use('/api', rssSyncRouter)
// ── Auth ───────────────────────────────────────────────────────
app.get ('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout)
app.get ('/api/auth/me', requireAuth, authMe)
app.post('/api/auth/change-password', requireAuth, authChangePassword)
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
// ── API publique v1 (auth par token) ──────────────────────────
app.use('/api', publicApiRouter)
// ── Routes (admin uniquement) ──────────────────────────────────
app.use('/api', requireAdmin, usersRouter)
app.use('/api', requireAdmin, jellyfinRouter)
app.use('/api', requireAdmin, settingsRouter)
app.use('/api', requireAdmin, torrentClientsRouter)
app.use('/api', requireAdmin, downloadsRouter)
app.use('/api', requireAdmin, organizeRouter)
app.use('/api', requireAdmin, importRouter)
app.use('/api', requireAdmin, systemRouter)
app.use('/api', requireAdmin, nfoUpdatesRouter)
app.use('/api', requireAdmin, plexRouter)
app.use('/api', requireAdmin, rssSyncRouter)
// ── Routes accessibles à tous les utilisateurs connectés ───────
app.use('/api', requireAuth, seriesRouter)
app.use('/api', requireAuth, requestsRouter)
// Invites : mix public + admin (le router gère ses propres middlewares)
app.use('/api', invitesRouter)
// ── Catch-all SPA ──────────────────────────────────────────────
if (fs.existsSync(PUBLIC_PATH)) {
+91
View File
@@ -0,0 +1,91 @@
/**
* Système d'invitations
*/
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
export interface Invite {
code : string
createdBy: string // userId de l'admin
createdAt: string
expiresAt: string | null // null = pas d'expiration
maxUses : number | null // null = utilisations illimitées
uses : number
note? : string
}
const INVITES_PATH = path.join(DATA_DIR, 'invites.json')
// ── Helpers fichier ───────────────────────────────────────────
export function readInvites(): Invite[] {
try {
if (!fs.existsSync(INVITES_PATH)) return []
return JSON.parse(fs.readFileSync(INVITES_PATH, 'utf-8')) as Invite[]
} catch { return [] }
}
function writeInvites(invites: Invite[]): void {
fs.mkdirSync(path.dirname(INVITES_PATH), { recursive: true })
fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2))
}
// ── CRUD ──────────────────────────────────────────────────────
export function createInvite(opts: {
createdBy: string
expiresInHours?: number | null
maxUses?: number | null
note?: string
}): Invite {
const invite: Invite = {
code : crypto.randomBytes(16).toString('hex'),
createdBy: opts.createdBy,
createdAt: new Date().toISOString(),
expiresAt: opts.expiresInHours
? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString()
: null,
maxUses : opts.maxUses ?? 1,
uses : 0,
note : opts.note,
}
writeInvites([...readInvites(), invite])
logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (maxUses: ${invite.maxUses ?? '∞'}, expire: ${invite.expiresAt ?? 'jamais'})`)
return invite
}
export function deleteInvite(code: string): void {
const invites = readInvites()
if (!invites.find(i => i.code === code)) throw new Error('Invitation introuvable')
writeInvites(invites.filter(i => i.code !== code))
}
export function findInvite(code: string): Invite | undefined {
return readInvites().find(i => i.code === code)
}
/** Vérifie si un code est valide (non expiré, non épuisé) */
export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } {
const invite = findInvite(code)
if (!invite) return { valid: false, reason: 'Code d\'invitation invalide' }
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
return { valid: false, reason: 'Ce lien d\'invitation a expiré' }
}
if (invite.maxUses !== null && invite.uses >= invite.maxUses) {
return { valid: false, reason: 'Ce lien d\'invitation a atteint son nombre maximum d\'utilisations' }
}
return { valid: true, invite }
}
/** Incrémente le compteur d'utilisations */
export function consumeInvite(code: string): void {
const invites = readInvites()
const invite = invites.find(i => i.code === code)
if (!invite) return
invite.uses++
writeInvites(invites)
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Helpers pour l'API Jellyfin
* Utilisé pour la sync d'utilisateurs et la validation SSO
*/
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"'
function adminHeaders(token: string): Record<string, string> {
return {
'X-Emby-Token': token,
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
'Content-Type': 'application/json',
}
}
function userHeaders(token: string): Record<string, string> {
return {
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
'Content-Type': 'application/json',
}
}
// ── Types Jellyfin ────────────────────────────────────────────
export interface JellyfinUser {
Id : string
Name : string
HasPassword : boolean
Policy : { IsAdministrator: boolean; IsDisabled: boolean }
}
// ── Test de connexion ─────────────────────────────────────────
export async function testJellyfinConnection(url: string, token: string): Promise<{ ok: boolean; version?: string; error?: string }> {
try {
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(token),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
const data = await res.json()
return { ok: true, version: data.Version }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
}
}
// ── Récupération des utilisateurs Jellyfin ────────────────────
export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou token manquant)')
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
if (!res.ok) throw new Error(`Jellyfin API ${res.status} : impossible de récupérer les utilisateurs`)
return res.json()
}
// ── Validation d'un token utilisateur Jellyfin (SSO) ──────────
export interface JellyfinTokenInfo {
valid : boolean
userId? : string
username?: string
error? : string
}
export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise<JellyfinTokenInfo> {
const { jellyfinUrl } = readSettings()
if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' }
try {
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
headers: userHeaders(jellyfinToken),
})
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Token Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin API ${res.status}` }
const user: JellyfinUser = await res.json()
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
logger.debug('jellyfin', `Token valide pour "${user.Name}" (${user.Id.slice(0, 8)}…)`)
return { valid: true, userId: user.Id, username: user.Name }
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
}
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Système de demandes utilisateurs
*/
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
import { findById } from './users.js'
export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed'
export interface Requester {
userId : string
username : string
seasons : number[] // numéros de saison ; [] = toutes les saisons dispo
requestedAt: string
}
export interface SerieRequest {
id : string
serieId : number
serieName : string
requesters : Requester[]
status : RequestStatus
rejectionMessage?: string
createdAt : string
updatedAt : string
}
const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json')
// ── Helpers fichier ───────────────────────────────────────────
export function readRequests(): SerieRequest[] {
try {
if (!fs.existsSync(REQUESTS_PATH)) return []
return JSON.parse(fs.readFileSync(REQUESTS_PATH, 'utf-8')) as SerieRequest[]
} catch { return [] }
}
function writeRequests(requests: SerieRequest[]): void {
fs.mkdirSync(path.dirname(REQUESTS_PATH), { recursive: true })
fs.writeFileSync(REQUESTS_PATH, JSON.stringify(requests, null, 2))
}
/** Union des saisons demandées sur tous les requesters ([] = toutes) */
export function mergedSeasons(req: SerieRequest): number[] {
if (req.requesters.some(r => r.seasons.length === 0)) return []
const all = new Set<number>()
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
return [...all].sort((a, b) => a - b)
}
// ── CRUD ──────────────────────────────────────────────────────
/**
* Créer ou fusionner une demande.
* - Si une demande active (non rejected/completed) existe pour la série → fusionne les saisons de cet user
* - Sinon → crée une nouvelle demande
*/
export function upsertRequest(
userId : string,
serieId : number,
serieName: string,
seasons : number[], // [] = toutes les saisons
): SerieRequest {
const user = findById(userId)
if (!user) throw new Error('Utilisateur introuvable')
const requests = readRequests()
// Chercher une demande active (pas rejected ni completed) pour cette série
const existing = requests.find(r =>
r.serieId === serieId &&
r.status !== 'rejected' &&
r.status !== 'completed'
)
const now = new Date().toISOString()
if (existing) {
// Mettre à jour ou ajouter ce requester dans la demande existante
const idx = existing.requesters.findIndex(r => r.userId === userId)
if (idx >= 0) {
// Mettre à jour les saisons (union)
if (seasons.length === 0 || existing.requesters[idx].seasons.length === 0) {
existing.requesters[idx].seasons = [] // l'une ou l'autre est "toutes" → toutes
} else {
const merged = new Set([...existing.requesters[idx].seasons, ...seasons])
existing.requesters[idx].seasons = [...merged].sort((a, b) => a - b)
}
existing.requesters[idx].requestedAt = now
} else {
existing.requesters.push({ userId, username: user.username, seasons, requestedAt: now })
}
existing.updatedAt = now
writeRequests(requests)
logger.info('requests', `Demande mise à jour pour "${serieName}" par "${user.username}"`)
return existing
}
// Nouvelle demande
const request: SerieRequest = {
id : crypto.randomUUID(),
serieId,
serieName,
requesters: [{ userId, username: user.username, seasons, requestedAt: now }],
status : 'pending',
createdAt : now,
updatedAt : now,
}
writeRequests([...requests, request])
logger.info('requests', `Nouvelle demande pour "${serieName}" par "${user.username}"`)
return request
}
export function approveRequest(id: string): SerieRequest {
const requests = readRequests()
const req = requests.find(r => r.id === id)
if (!req) throw new Error('Demande introuvable')
req.status = 'approved'
req.rejectionMessage = undefined
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `Demande "${req.serieName}" approuvée`)
return req
}
export function rejectRequest(id: string, message?: string): SerieRequest {
const requests = readRequests()
const req = requests.find(r => r.id === id)
if (!req) throw new Error('Demande introuvable')
req.status = 'rejected'
req.rejectionMessage = message
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `Demande "${req.serieName}" refusée`)
return req
}
export function completeRequest(id: string): SerieRequest {
const requests = readRequests()
const req = requests.find(r => r.id === id)
if (!req) throw new Error('Demande introuvable')
req.status = 'completed'
req.updatedAt = new Date().toISOString()
writeRequests(requests)
return req
}
export function deleteRequest(id: string): void {
const requests = readRequests()
if (!requests.find(r => r.id === id)) throw new Error('Demande introuvable')
writeRequests(requests.filter(r => r.id !== id))
}
export function getRequestsForUser(userId: string): SerieRequest[] {
return readRequests().filter(r => r.requesters.some(req => req.userId === userId))
}
export function getPendingCount(): number {
return readRequests().filter(r => r.status === 'pending').length
}
+75
View File
@@ -0,0 +1,75 @@
import { Router } from 'express'
import { requireAdmin } from '../auth.js'
import {
readInvites, createInvite, deleteInvite,
validateInvite, consumeInvite,
} from '../invites.js'
import { createUser, safeUser } from '../users.js'
const router = Router()
// GET /api/invites (admin)
router.get('/invites', requireAdmin, (_req, res) => {
res.json(readInvites())
})
// POST /api/invites (admin)
router.post('/invites', requireAdmin, (req, res) => {
const { expiresInHours, maxUses, note } = req.body
try {
const invite = createInvite({
createdBy : req.user!.id,
expiresInHours: expiresInHours ?? null,
maxUses : maxUses ?? 1,
note,
})
res.json(invite)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// DELETE /api/invites/:code (admin)
router.delete('/invites/:code', requireAdmin, (req, res) => {
try {
deleteInvite(req.params.code)
res.json({ success: true })
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// GET /api/invites/:code/info (public — pour afficher la page d'inscription)
router.get('/invites/:code/info', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
res.status(400).json({ valid: false, reason: result.reason }); return
}
res.json({ valid: true, note: result.invite.note ?? null })
})
// POST /api/invites/:code/register (public — inscription via invitation)
router.post('/invites/:code/register', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
res.status(400).json({ error: result.reason }); return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'username et password requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, 'user')
consumeInvite(req.params.code)
res.json({ success: true, user: safeUser(user) })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
export default router
+73
View File
@@ -0,0 +1,73 @@
import { Router } from 'express'
import { readSettings, writeSettings } from '../settings.js'
import { readUsers, createUser, safeUser } from '../users.js'
import { testJellyfinConnection, fetchJellyfinUsers } from '../lib/jellyfin.js'
import { logger } from '../logger.js'
const router = Router()
// GET /api/jellyfin/settings
router.get('/jellyfin/settings', (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
res.json({
jellyfinUrl,
// Ne jamais retourner le token en clair — juste indiquer s'il est configuré
hasToken: !!jellyfinAdminToken,
})
})
// POST /api/jellyfin/settings
router.post('/jellyfin/settings', (req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = req.body
const update: Record<string, string> = {}
if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim()
if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim()
const updated = writeSettings(update)
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
})
// POST /api/jellyfin/test
router.post('/jellyfin/test', async (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) {
res.status(400).json({ ok: false, error: 'URL et token requis' }); return
}
const result = await testJellyfinConnection(jellyfinUrl, jellyfinAdminToken)
res.json(result)
})
// POST /api/jellyfin/sync
router.post('/jellyfin/sync', async (_req, res) => {
try {
const jellyfinUsers = await fetchJellyfinUsers()
const fankarrUsers = readUsers()
const results = { created: 0, skipped: 0, users: [] as string[] }
for (const jUser of jellyfinUsers) {
// Ignorer les comptes désactivés
if (jUser.Policy?.IsDisabled) { results.skipped++; continue }
const exists = fankarrUsers.some(u =>
u.username.toLowerCase() === jUser.Name.toLowerCase()
)
if (exists) { results.skipped++; continue }
// Créer le compte avec un mot de passe aléatoire (auth via SSO uniquement)
const randomPass = Math.random().toString(36).slice(2, 10) +
Math.random().toString(36).slice(2, 10)
createUser(jUser.Name, randomPass, 'user')
results.created++
results.users.push(jUser.Name)
logger.info('jellyfin', `Compte créé pour "${jUser.Name}" (sync Jellyfin)`)
}
logger.info('jellyfin', `Sync terminée — ${results.created} créés, ${results.skipped} ignorés`)
res.json(results)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur sync Jellyfin' })
}
})
export default router
+125
View File
@@ -0,0 +1,125 @@
/**
* API publique v1 — pour le plugin Jellyfin et intégrations tierces
* Auth : Bearer {apiToken} dans le header Authorization
*/
import { Router } from 'express'
import { findByApiToken } from '../users.js'
import { validateJellyfinToken } from '../lib/jellyfin.js'
import { readUsers, safeUser } from '../users.js'
import { upsertRequest, getRequestsForUser } from '../requests.js'
import { readAvailable } from '../lib/github-cache.js'
import { fankaiGet, normalizeSerie } from '../lib/serie-helpers.js'
import { logger } from '../logger.js'
import type { Request, Response, NextFunction } from 'express'
const router = Router()
// ── Middleware auth API token ─────────────────────────────────
function requireApiToken(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Token requis (Authorization: Bearer <token>)' }); return
}
const user = findByApiToken(header.slice(7))
if (!user) {
res.status(401).json({ error: 'Token invalide' }); return
}
req.user = user
next()
}
// ── POST /api/v1/auth/jellyfin ────────────────────────────────
// Échange un token Jellyfin contre un token FanKarr
// Body: { jellyfinUserId, jellyfinToken }
router.post('/v1/auth/jellyfin', async (req, res) => {
const { jellyfinUserId, jellyfinToken } = req.body
if (!jellyfinUserId || !jellyfinToken) {
res.status(400).json({ error: 'jellyfinUserId et jellyfinToken requis' }); return
}
const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken)
if (!info.valid) {
res.status(401).json({ error: info.error ?? 'Authentification Jellyfin échouée' }); return
}
// Trouver l'utilisateur FanKarr correspondant (par username)
const users = readUsers()
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
if (!fanUser) {
logger.warn('public-api', `SSO Jellyfin : aucun compte FanKarr pour "${info.username}" — sync requise`)
res.status(404).json({
error: `Aucun compte FanKarr pour "${info.username}". Demandez à l'admin de lancer la synchronisation Jellyfin.`,
}); return
}
logger.info('public-api', `SSO Jellyfin réussi pour "${fanUser.username}"`)
res.json({
token : fanUser.apiToken,
username: fanUser.username,
role : fanUser.role,
})
})
// ── GET /api/v1/auth/me ───────────────────────────────────────
router.get('/v1/auth/me', requireApiToken, (req, res) => {
res.json(safeUser(req.user!))
})
// ── GET /api/v1/series/search?q= ─────────────────────────────
// Recherche dans le catalogue FanKai
router.get('/v1/series/search', requireApiToken, async (req, res) => {
const q = String(req.query.q ?? '').trim().toLowerCase()
try {
const [apiData, availableIds] = await Promise.all([
fankaiGet('/series'),
readAvailable(),
])
const availableSet = new Set<number>(availableIds)
const all: any[] = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const results = all
.filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q))
.slice(0, 50)
.map(s => ({
id : s.id,
title : s.title,
original_title: s.original_title ?? null,
image : s.image ?? null,
available : availableSet.has(s.id),
}))
res.json(results)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// ── POST /api/v1/requests ─────────────────────────────────────
// Créer ou mettre à jour une demande
router.post('/v1/requests', requireApiToken, (req, res) => {
const { serieId, serieName, seasons } = req.body
if (!serieId || !serieName) {
res.status(400).json({ error: 'serieId et serieName requis' }); return
}
try {
const request = upsertRequest(
req.user!.id,
Number(serieId),
String(serieName),
Array.isArray(seasons) ? seasons.map(Number) : [],
)
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// ── GET /api/v1/requests ──────────────────────────────────────
// Demandes de l'utilisateur authentifié
router.get('/v1/requests', requireApiToken, (req, res) => {
res.json(getRequestsForUser(req.user!.id))
})
export default router
+71
View File
@@ -0,0 +1,71 @@
import { Router } from 'express'
import { requireAdmin } from '../auth.js'
import {
readRequests, upsertRequest, approveRequest,
rejectRequest, completeRequest, deleteRequest,
getRequestsForUser, getPendingCount, mergedSeasons,
} from '../requests.js'
const router = Router()
// GET /api/requests — admin voit tout, user voit les siennes
router.get('/requests', (req, res) => {
const list = req.user!.role === 'admin'
? readRequests()
: getRequestsForUser(req.user!.id)
res.json(list)
})
// GET /api/requests/pending-count — pour le widget dashboard
router.get('/requests/pending-count', (req, res) => {
const count = req.user!.role === 'admin'
? getPendingCount()
: getRequestsForUser(req.user!.id).filter(r => r.status === 'pending').length
res.json({ count })
})
// POST /api/requests — créer ou mettre à jour une demande
router.post('/requests', (req, res) => {
const { serieId, serieName, seasons } = req.body
if (!serieId || !serieName) {
res.status(400).json({ error: 'serieId et serieName requis' }); return
}
try {
const request = upsertRequest(
req.user!.id,
Number(serieId),
String(serieName),
Array.isArray(seasons) ? seasons.map(Number) : [],
)
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// PATCH /api/requests/:id — admin : approve / reject / complete
router.patch('/requests/:id', requireAdmin, (req, res) => {
const { action, rejectionMessage } = req.body
try {
let request
if (action === 'approve') request = approveRequest(req.params.id)
else if (action === 'reject') request = rejectRequest(req.params.id, rejectionMessage)
else if (action === 'complete') request = completeRequest(req.params.id)
else { res.status(400).json({ error: 'Action invalide (approve | reject | complete)' }); return }
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// DELETE /api/requests/:id — admin seulement
router.delete('/requests/:id', requireAdmin, (req, res) => {
try {
deleteRequest(req.params.id)
res.json({ success: true })
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
export default router
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express'
import {
readUsers, createUser, updateUser, deleteUser,
regenerateApiToken, safeUser,
} from '../users.js'
const router = Router()
// GET /api/users
router.get('/users', (_req, res) => {
res.json(readUsers().map(safeUser))
})
// POST /api/users
router.post('/users', (req, res) => {
const { username, password, role } = req.body
if (!username || !password) {
res.status(400).json({ error: 'username et password requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, role === 'admin' ? 'admin' : 'user')
res.json(safeUser(user))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// PATCH /api/users/:id
router.patch('/users/:id', (req, res) => {
const { username, password, role } = req.body
try {
const user = updateUser(req.params.id, { username, password, role })
res.json(safeUser(user))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// DELETE /api/users/:id
router.delete('/users/:id', (req, res) => {
// Empêche l'admin de se supprimer lui-même
if (req.user?.id === req.params.id) {
res.status(400).json({ error: 'Impossible de supprimer son propre compte' }); return
}
try {
deleteUser(req.params.id)
res.json({ success: true })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
// POST /api/users/:id/regenerate-token
router.post('/users/:id/regenerate-token', (req, res) => {
try {
const token = regenerateApiToken(req.params.id)
res.json({ apiToken: token })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
})
export default router
+15 -9
View File
@@ -7,23 +7,29 @@ const DATA_PATH = path.join(DATA_DIR, 'settings.json')
export interface Settings {
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'copy' | 'move' // ← ajouter 'copy'
organizeMode : 'hardlink' | 'copy' | 'move'
category : string
nfoSupport : boolean
autoImport : boolean
devMode : boolean
deleteTorrentOnMove : boolean // ← ajouter
deleteTorrentOnMove : boolean
requestAutoImport : boolean // demandes users : import auto ou validation admin
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
jellyfinAdminToken : string // Token admin Jellyfin pour la sync
}
const defaults: Settings = {
mediaPath : '',
completePath: '',
organizeMode: 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
devMode : false,
mediaPath : '',
completePath : '',
organizeMode : 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
devMode : false,
deleteTorrentOnMove: false,
requestAutoImport : false,
jellyfinUrl : '',
jellyfinAdminToken : '',
}
export function readSettings(): Settings {
+196
View File
@@ -0,0 +1,196 @@
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
const AD_BASE = 'https://api.alldebrid.com/v4'
const AD_AGENT = 'FanKarr'
function adUrl(config: Record<string, string | number>, path: string): string {
const sep = path.includes('?') ? '&' : '?'
return `${AD_BASE}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}`
}
async function adFetch(
config: Record<string, string | number>,
method: 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams | FormData,
): Promise<any> {
const res = await fetch(adUrl(config, path), { method, body })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`AllDebrid HTTP ${res.status} : ${text}`)
}
const json = await res.json()
if (json.status === 'error') {
throw new Error(`AllDebrid : ${json.error?.message ?? json.error?.code ?? 'Erreur inconnue'}`)
}
return json.data
}
// statusCode : 0=Processing, 1=Downloading, 2=Compressing, 3=Uploading, 4=Ready,
// 5=Upload Fail, 6=Internal Error, 7=Not Downloaded, 8=File Not Available,
// 9=Banned BitTorrent, 10=Awaiting Slot, 11=Free Trial Limit Reached
function mapStatus(statusCode: number): TorrentInfo['state'] {
switch (statusCode) {
case 4: return 'seeding'
case 1:
case 0:
case 2:
case 3:
case 10: return 'downloading'
case 5:
case 6:
case 7:
case 8:
case 9:
case 11: return 'error'
default: return 'unknown'
}
}
function flattenFiles(nodes: any[], prefix = '', startIdx = 0): TorrentFileProgress[] {
const result: TorrentFileProgress[] = []
let idx = startIdx
for (const node of nodes) {
if (node.e) {
const children = flattenFiles(node.e, prefix ? `${prefix}/${node.n}` : node.n, idx)
result.push(...children)
idx += children.length
} else if (node.n) {
result.push({ index: idx++, name: prefix ? `${prefix}/${node.n}` : node.n, progress: 0, priority: 1 })
}
}
return result
}
const adDriver: TorrentClientDriver = {
definition: {
id : 'alldebrid',
label : 'AllDebrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/alldebrid', required: false },
],
},
async test(config) {
try {
const data = await adFetch(config, 'GET', '/user')
const username = data.user?.username ?? data.user?.login ?? '?'
logger.info('alldebrid', `Connexion réussie — utilisateur : ${username}`)
return { ok: true, message: `Connecté en tant que ${username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('alldebrid', `Test de connexion échoué : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const data = await adFetch(config, 'GET', '/user')
const username = data.user?.username ?? data.user?.login ?? '?'
return { online: true, version: `AD — ${username}` }
} catch {
return { online: false }
}
},
async add(config, url, options?: DownloadOptions) {
const isMagnet = url.startsWith('magnet:')
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
if (hash) {
try {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const existing = magnets.find((m: any) => m.hash?.toLowerCase() === hash)
if (existing) {
logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur AllDebrid`)
return
}
} catch {}
}
if (isMagnet) {
const body = new URLSearchParams()
body.append('magnets[]', url)
const data = await adFetch(config, 'POST', '/magnet/upload', body)
const id = data.magnets?.[0]?.id
logger.info('alldebrid', `Magnet ajouté → ID ${id}`)
} else {
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('files[]', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
const data = await adFetch(config, 'POST', '/magnet/upload/file', formData)
const id = data.magnets?.[0]?.id
logger.info('alldebrid', `.torrent uploadé → ID ${id}`)
}
},
async list(config, _category?) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
return magnets.map((m: any): TorrentInfo => {
const statusCode = typeof m.statusCode === 'number' ? m.statusCode : 0
const size = typeof m.size === 'number' ? m.size : 0
const downloaded = typeof m.downloaded === 'number' ? m.downloaded : 0
const progress = size > 0
? Math.round((downloaded / size) * 100)
: (statusCode === 4 ? 100 : 0)
const save_path = localBase ? `${localBase}/${m.filename ?? m.id}` : ''
return {
hash : String(m.hash ?? m.id ?? '').toLowerCase(),
name : String(m.filename ?? m.id ?? ''),
state : mapStatus(statusCode),
progress,
size,
downloaded,
uploaded : 0,
ratio : 0,
speed : typeof m.speed === 'number' ? m.speed : 0,
upspeed : 0,
eta : typeof m.eta === 'number' ? m.eta : -1,
save_path,
category : '',
}
})
},
async remove(config, hash, _deleteFiles?) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur AllDebrid`)
await adFetch(config, 'DELETE', `/magnet/delete?id=${target.id}`)
logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… supprimé de AllDebrid`)
},
async getFiles(config, hash) {
const data = await adFetch(config, 'GET', '/magnet/status')
const magnets: any[] = data.magnets ?? []
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) return []
const detail = await adFetch(config, 'GET', `/magnet/status?id=${target.id}`)
const magnet = Array.isArray(detail.magnets) ? detail.magnets[0] : detail
const statusCode = typeof magnet.statusCode === 'number' ? magnet.statusCode : 0
const size = typeof magnet.size === 'number' ? magnet.size : 0
const downloaded = typeof magnet.downloaded === 'number' ? magnet.downloaded : 0
const overallProgress = size > 0 ? downloaded / size : (statusCode === 4 ? 1 : 0)
if (!Array.isArray(magnet.files)) return []
return flattenFiles(magnet.files).map(f => ({ ...f, progress: overallProgress }))
},
}
export default adDriver
+152
View File
@@ -0,0 +1,152 @@
/**
* Gestion des utilisateurs multi-user
* Migration automatique depuis l'ancien auth.json mono-user
*/
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import bcrypt from 'bcryptjs'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
export type UserRole = 'admin' | 'user'
export interface User {
id : string
username : string
passwordHash: string
role : UserRole
apiToken : string
createdAt : string
}
const USERS_PATH = path.join(DATA_DIR, 'users.json')
const LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
// ── Migration mono → multi-user ───────────────────────────────
export function migrateIfNeeded(): void {
if (fs.existsSync(USERS_PATH)) return // déjà en format multi-user
if (!fs.existsSync(LEGACY_PATH)) return // pas encore configuré
try {
const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as {
username: string
passwordHash: string
}
const admin: User = {
id : crypto.randomUUID(),
username : legacy.username,
passwordHash: legacy.passwordHash,
role : 'admin',
apiToken : crypto.randomBytes(32).toString('hex'),
createdAt : new Date().toISOString(),
}
writeUsers([admin])
logger.info('users', `Migration auth.json → users.json réussie pour "${legacy.username}"`)
} catch (err) {
logger.error('users', `Échec migration auth.json : ${err instanceof Error ? err.message : err}`)
}
}
// ── Helpers fichier ───────────────────────────────────────────
export function readUsers(): User[] {
try {
if (!fs.existsSync(USERS_PATH)) return []
return JSON.parse(fs.readFileSync(USERS_PATH, 'utf-8')) as User[]
} catch { return [] }
}
export function writeUsers(users: User[]): void {
fs.mkdirSync(path.dirname(USERS_PATH), { recursive: true })
fs.writeFileSync(USERS_PATH, JSON.stringify(users, null, 2))
}
export function hasUsers(): boolean { return readUsers().length > 0 }
export function hasAdmin(): boolean { return readUsers().some(u => u.role === 'admin') }
export function findById(id: string): User | undefined { return readUsers().find(u => u.id === id) }
export function findByUsername(username: string): User | undefined { return readUsers().find(u => u.username.toLowerCase() === username.toLowerCase()) }
export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) }
/** Sérialise sans passwordHash pour les réponses API */
export function safeUser(user: User) {
const { passwordHash: _, ...safe } = user
return safe
}
// ── CRUD ──────────────────────────────────────────────────────
export function createUser(username: string, password: string, role: UserRole = 'user'): User {
const users = readUsers()
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Nom d'utilisateur "${username}" déjà utilisé`)
}
const user: User = {
id : crypto.randomUUID(),
username,
passwordHash: bcrypt.hashSync(password, SALT_ROUNDS),
role,
apiToken : crypto.randomBytes(32).toString('hex'),
createdAt : new Date().toISOString(),
}
writeUsers([...users, user])
logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`)
return user
}
export function updateUser(
id : string,
changes: Partial<{ username: string; password: string; role: UserRole }>,
): User {
const users = readUsers()
const idx = users.findIndex(u => u.id === id)
if (idx === -1) throw new Error('Utilisateur introuvable')
const user = users[idx]
if (changes.username !== undefined && changes.username !== user.username) {
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) {
throw new Error(`Nom d'utilisateur "${changes.username}" déjà utilisé`)
}
user.username = changes.username
}
if (changes.role !== undefined) user.role = changes.role
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Utilisateur "${user.username}" mis à jour`)
return user
}
export function deleteUser(id: string): void {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
writeUsers(users.filter(u => u.id !== id))
logger.info('users', `Utilisateur "${user.username}" supprimé`)
}
export function changePassword(id: string, currentPassword: string, newPassword: string): void {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
if (!bcrypt.compareSync(currentPassword, user.passwordHash)) {
throw new Error('Mot de passe actuel incorrect')
}
user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Mot de passe changé pour "${user.username}"`)
}
export function regenerateApiToken(id: string): string {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
user.apiToken = crypto.randomBytes(32).toString('hex')
writeUsers(users)
return user.apiToken
}
+106 -62
View File
@@ -31,18 +31,81 @@
<!-- Boutons saison -->
<div class="flex items-center gap-2">
<!-- Corbeille saison -->
<!-- Corbeille saison (admin uniquement) -->
<button
v-if="season.organized_count > 0"
v-if="!requestMode && season.organized_count > 0"
@click.stop="openUnimportSeasonModal"
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20"
title="Désimporter la saison"
>
<Trash2 :size="14" />
</button>
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
<template v-if="season.torrents.length === 0 && hasDownloadable">
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
<!-- ── Mode demande ────────────────────────────────────── -->
<template v-if="requestMode">
<button
@click.stop="emit('requestSeason', season.season_number)"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="isSeasonRequested
? 'bg-blue-500/10 text-blue-400 border-blue-500/20 cursor-default'
: 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'"
>
<component :is="isSeasonRequested ? checkIcon : requestIcon" />
{{ isSeasonRequested ? 'Demandé' : 'Demander' }}
</button>
</template>
<!-- ── Mode téléchargement (admin) ─────────────────────── -->
<template v-else>
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
<template v-if="season.torrents.length === 0 && hasDownloadable">
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
>
<component :is="downloadIcon" />
Saison ▾
</button>
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
<button
v-for="opt in uniquePackOptions"
:key="opt.infohash"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
>
<component :is="downloadIcon" />
{{ opt.label }}
</button>
</div>
</div>
<button
v-else
@click="emit('downloadSeason', season)"
:disabled="downloadingSeason"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
>
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
</button>
</template>
<!-- Pack saison unique -->
<button
v-if="season.torrents.length === 1"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="canDownloadSeason
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!canDownloadSeason"
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<component :is="seasonBtnIcon" />
{{ seasonBtnLabel }}
</button>
<!-- Dropdown packs saison multiples -->
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
@@ -50,66 +113,21 @@
<component :is="downloadIcon" />
Saison ▾
</button>
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<button
v-for="opt in uniquePackOptions"
:key="opt.infohash"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
v-for="(t, i) in season.torrents"
:key="i"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
>
<component :is="downloadIcon" />
{{ opt.label }}
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(season.torrents)[i] }}
</button>
</div>
</div>
<button
v-else
@click="emit('downloadSeason', season)"
:disabled="downloadingSeason"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
>
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
</button>
</template>
<!-- Pack saison unique -->
<button
v-if="season.torrents.length === 1"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="canDownloadSeason
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!canDownloadSeason"
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<component :is="seasonBtnIcon" />
{{ seasonBtnLabel }}
</button>
<!-- Dropdown packs saison multiples -->
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
@click="seasonMenuOpen = !seasonMenuOpen"
>
<component :is="downloadIcon" />
Saison ▾
</button>
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<button
v-for="(t, i) in season.torrents"
:key="i"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
>
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(season.torrents)[i] }}
</button>
</div>
</div>
</div>
</div>
@@ -182,9 +200,22 @@
</div>
</div>
<!-- Bouton téléchargement épisode (uniquement si non organisé) -->
<!-- Bouton épisode -->
<div v-if="!ep.organized" class="relative shrink-0" @click.stop>
<!-- Mode demande -->
<button
v-if="requestMode"
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
:class="(requestedSeasons?.includes(season.season_number))
? 'border-blue-500/30 text-blue-400 bg-blue-500/10 cursor-default'
: 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'"
@click="!requestedSeasons?.includes(season.season_number) && emit('requestEpisode', season.season_number, ep.id)"
>
<component :is="requestedSeasons?.includes(season.season_number) ? checkIcon : requestIcon" />
</button>
<!-- Mode téléchargement (admin) -->
<button
v-else
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
:class="epBtnClass(ep)"
:disabled="epState(ep) !== 'idle'"
@@ -209,8 +240,8 @@
</div>
</div>
<!-- Actions épisode importé -->
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Actions épisode importé (admin uniquement) -->
<template v-if="!requestMode && ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Badge rename cliquable -->
<button
v-if="epNeedsRename(ep)"
@@ -314,6 +345,8 @@ const props = defineProps<{
epActionLoading : Record<number, boolean>
downloadingSeason ?: boolean
nfoSupport ?: boolean
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
requestedSeasons ?: number[] // saisons déjà demandées par cet user pour cette série
}>()
const emit = defineEmits<{
@@ -323,8 +356,19 @@ const emit = defineEmits<{
renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
unimportSeason : [season: any, deleteFiles: boolean]
// Mode demande
requestSeason : [seasonNumber: number]
requestEpisode : [seasonNumber: number, episodeId: number]
}>()
const requestIcon = h('svg', { viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': 2, fill: 'none', width: 12, height: 12 },
[h('circle', { cx: 12, cy: 12, r: 10 }), h('path', { d: 'M12 8v4l2 2' })]
)
const isSeasonRequested = computed(() =>
props.requestedSeasons?.includes(props.season.season_number) ?? false
)
const epOptionsOpen = ref<number | null>(null)
const plotOpen = ref<number | null>(null)
const seasonMenuOpen = ref(false)
+55 -30
View File
@@ -39,46 +39,71 @@ import { ref, computed } from 'vue'
import { Menu } from 'lucide-vue-next'
import SidebarNav from '@/components/nav/SidebarNav.vue'
import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth'
import type { NavItem } from '@/types/nav'
const mobileOpen = ref(false)
const dlStore = useDownloadsStore()
const auth = useAuthStore()
const navItems = computed<NavItem[]>(() => [
{
label: 'Dashboard',
icon : 'LayoutDashboard',
to : '/dashboard',
},
{
label : 'Médiathèque',
icon : 'Tv',
to : '/series',
children: [
{ label: 'Séries', to: '/series' },
],
},
{
label: 'Activité',
icon : 'Activity',
to : '/activity',
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
},
{ separator: true },
{
label : 'Paramètres',
icon : 'Settings',
to : '/settings',
children: [
const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
label: 'Dashboard',
icon : 'LayoutDashboard',
to : '/dashboard',
},
{
label : 'Médiathèque',
icon : 'Tv',
to : '/series',
children: [
{ label: 'Séries', to: '/series' },
],
},
{
label: 'Demandes',
icon : 'ClipboardList',
to : '/requests',
},
]
if (auth.isAdmin) {
items.push({
label: 'Activité',
icon : 'Activity',
to : '/activity',
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
})
}
items.push({ separator: true })
const settingsChildren: NavItem[] = [
{ label: 'Mon profil', to: '/settings/profile' },
]
if (auth.isAdmin) {
settingsChildren.push(
{ label: 'Clients de téléchargement', to: '/settings/download-client' },
{ label: 'Gestion des médias', to: '/settings/media-management' },
{ label: 'Management des séries', to: '/settings/import-management' },
{ label: 'Management des séries', to: '/settings/import-management' },
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
{ label: 'Journaux', to: '/settings/logs' },
{ label: 'Avancé', to: '/settings/advanced' },
],
},
])
{ label: 'Utilisateurs', to: '/settings/users' },
{ label: 'Jellyfin & API', to: '/settings/jellyfin' },
)
}
items.push({
label : 'Paramètres',
icon : 'Settings',
to : '/settings',
children: settingsChildren,
})
return items
})
</script>
<style scoped>
+47 -12
View File
@@ -1,16 +1,28 @@
import { createRouter, createWebHistory } from 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
public? : boolean
adminOnly? : boolean
}
}
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
// ─── Page publique hors layout ───────────────────────────
// ─── Pages publiques hors layout ─────────────────────────
{
path: '/auth',
component: () => import('@/views/AuthView.vue'),
meta: { public: true },
},
{
path: '/invite/:code',
component: () => import('@/views/InviteView.vue'),
meta: { public: true },
},
// ─── App principale avec layout sidebar ──────────────────
{
@@ -52,7 +64,12 @@ const router = createRouter({
path: 'settings',
component: () => import('@/views/settings/SettingsLayout.vue'),
children: [
{ path: '', redirect: '/settings/download-client' },
{ path: '', redirect: '/settings/profile' },
{
path: 'profile',
name: 'settings-profile',
component: () => import('@/views/settings/ProfileView.vue'),
},
{
path: 'download-client',
name: 'settings-download',
@@ -83,8 +100,27 @@ const router = createRouter({
name: 'settings-advanced',
component: () => import('@/views/settings/AdvancedView.vue'),
},
{
path: 'users',
name: 'settings-users',
component: () => import('@/views/settings/UsersView.vue'),
meta: { adminOnly: true },
},
{
path: 'jellyfin',
name: 'settings-jellyfin',
component: () => import('@/views/settings/JellyfinView.vue'),
meta: { adminOnly: true },
},
],
},
// Demandes
{
path: 'requests',
name: 'requests',
component: () => import('@/views/RequestsView.vue'),
},
],
},
],
@@ -97,21 +133,20 @@ const router = createRouter({
},
})
// ─── Guard auth (inchangé) ────────────────────────────────────
// ─── Guard auth ───────────────────────────────────────────────
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (auth.loading) {
await auth.checkStatus()
}
if (auth.loading) await auth.checkStatus()
if (!to.meta.public && !auth.loggedIn) {
return '/auth'
}
// Redirige vers /auth si non connecté
if (!to.meta.public && !auth.loggedIn) return '/auth'
if (to.path === '/auth' && auth.loggedIn) {
return '/series'
}
// Redirige vers /series si déjà connecté et tente d'accéder à /auth
if (to.path === '/auth' && auth.loggedIn) return '/series'
// Redirige les non-admins hors des pages admin
if (to.meta.adminOnly && !auth.isAdmin) return '/series'
})
export default router
+34 -17
View File
@@ -1,17 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const loggedIn = ref(false)
const setup = ref(false)
const loading = ref(true)
const loggedIn = ref(false)
const setup = ref(false)
const loading = ref(true)
const role = ref<'admin' | 'user' | null>(null)
const username = ref<string | null>(null)
const userId = ref<string | null>(null)
const isAdmin = computed(() => role.value === 'admin')
async function checkStatus() {
try {
const res = await fetch('/api/auth/status')
const res = await fetch('/api/auth/status')
const data = await res.json()
setup.value = data.setup
setup.value = data.setup
loggedIn.value = data.loggedIn
role.value = data.role ?? null
username.value = data.username ?? null
userId.value = data.userId ?? null
} catch {
loggedIn.value = false
} finally {
@@ -19,30 +27,36 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function login(username: string, password: string): Promise<string | null> {
const res = await fetch('/api/auth/login', {
method: 'POST',
async function login(u: string, password: string): Promise<string | null> {
const res = await fetch('/api/auth/login', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body : JSON.stringify({ username: u, password }),
})
const data = await res.json()
if (res.ok) {
loggedIn.value = true
role.value = data.user?.role ?? null
username.value = data.user?.username ?? null
userId.value = data.user?.id ?? null
return null
}
return data.error
}
async function setupAccount(username: string, password: string): Promise<string | null> {
const res = await fetch('/api/auth/setup', {
method: 'POST',
async function setupAccount(u: string, password: string): Promise<string | null> {
const res = await fetch('/api/auth/setup', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body : JSON.stringify({ username: u, password }),
})
const data = await res.json()
if (res.ok) {
loggedIn.value = true
setup.value = true
setup.value = true
role.value = data.user?.role ?? 'admin'
username.value = data.user?.username ?? u
userId.value = data.user?.id ?? null
return null
}
return data.error
@@ -51,7 +65,10 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' })
loggedIn.value = false
role.value = null
username.value = null
userId.value = null
}
return { loggedIn, setup, loading, checkStatus, login, setupAccount, logout }
})
return { loggedIn, setup, loading, role, username, userId, isAdmin, checkStatus, login, setupAccount, logout }
})
+63 -1
View File
@@ -80,6 +80,48 @@
</div>
<!-- Widget demandes -->
<div class="settings-card flex flex-col gap-3 mb-6">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-secondary">
{{ auth.isAdmin ? 'Demandes en attente' : 'Mes demandes' }}
</h2>
<div class="flex items-center gap-2">
<span v-if="auth.isAdmin && pendingRequests.length > 0" class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
{{ pendingRequests.length }} en attente
</span>
<RouterLink to="/requests" class="text-xs text-muted hover:text-accent transition">Voir tout →</RouterLink>
</div>
</div>
<div v-if="loadingRequests" class="flex items-center gap-2 text-muted text-xs py-2">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<div v-else-if="pendingRequests.length === 0" class="text-xs text-muted py-2">
{{ auth.isAdmin ? 'Aucune demande en attente' : 'Aucune demande' }}
</div>
<div v-else class="flex flex-col divide-y divide-border/50">
<div v-for="req in pendingRequests" :key="req.id" class="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
<div class="flex-1 min-w-0">
<RouterLink :to="`/series/${req.serieId}`" class="text-xs text-primary truncate hover:text-accent transition block">{{ req.serieName }}</RouterLink>
<p v-if="auth.isAdmin" class="text-[11px] text-muted mt-0.5">
{{ req.requesters.length }} demandeur{{ req.requesters.length > 1 ? 's' : '' }}
· {{ req.requesters.map((r: any) => r.username).join(', ') }}
</p>
</div>
<span class="text-[10px] px-1.5 py-0.5 rounded border shrink-0"
:class="{
'bg-yellow-500/10 text-yellow-400 border-yellow-500/20': req.status === 'pending',
'bg-blue-500/10 text-blue-400 border-blue-500/20' : req.status === 'approved',
'bg-green-500/10 text-green-400 border-green-500/20' : req.status === 'completed',
'bg-red-500/10 text-red-400 border-red-500/20' : req.status === 'rejected',
}">
{{ { pending: 'Attente', approved: 'Approuvée', completed: 'Dispo', rejected: 'Refusée' }[req.status as string] }}
</span>
</div>
</div>
</div>
<!-- Panneau debug -->
<div v-if="devMode" class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@@ -129,7 +171,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { RouterLink } from 'vue-router'
const auth = useAuthStore()
const loadingTorrents = ref(true)
const loadingNotifs = ref(true)
const devMode = ref(false)
@@ -137,6 +182,8 @@ const debug = ref<any>(null)
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
const activeTorrents = ref<any[]>([])
const recentNotifs = ref<any[]>([])
const pendingRequests = ref<any[]>([])
const loadingRequests = ref(true)
let interval: ReturnType<typeof setInterval> | null = null
@@ -198,6 +245,21 @@ async function fetchNotifs() {
}
}
async function fetchRequests() {
loadingRequests.value = true
try {
const res = await fetch('/api/requests', { credentials: 'include' })
if (res.ok) {
const all = await res.json()
pendingRequests.value = auth.isAdmin
? all.filter((r: any) => r.status === 'pending').slice(0, 5)
: all.slice(0, 5)
}
} catch {} finally {
loadingRequests.value = false
}
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -223,7 +285,7 @@ function formatRelative(iso: string): string {
}
onMounted(() => {
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings(), fetchRequests()])
interval = setInterval(() => {
fetchTorrents()
if (devMode.value) fetchDebug()
+118
View File
@@ -0,0 +1,118 @@
<template>
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<!-- Logo -->
<div class="flex items-center justify-center gap-2.5 mb-8">
<svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
</div>
<!-- Chargement -->
<div v-if="status === 'loading'" class="settings-card p-8 text-center text-sm text-muted">
Vérification du lien…
</div>
<!-- Lien invalide -->
<div v-else-if="status === 'invalid'" class="settings-card p-8 flex flex-col gap-3 text-center">
<p class="text-sm font-semibold text-red-400">Lien invalide</p>
<p class="text-sm text-muted">{{ invalidReason }}</p>
</div>
<!-- Succès -->
<div v-else-if="status === 'done'" class="settings-card p-8 flex flex-col gap-4 text-center">
<p class="text-sm font-semibold text-green-400">Compte créé !</p>
<p class="text-sm text-muted">Tu peux maintenant te connecter.</p>
<RouterLink to="/auth" class="btn-primary w-full py-2.5 text-center">Se connecter</RouterLink>
</div>
<!-- Formulaire d'inscription -->
<div v-else class="settings-card p-8 flex flex-col gap-5">
<div>
<p class="text-xs text-accent font-medium mb-1">Invitation FanKarr</p>
<h1 class="text-lg font-semibold text-primary">Créer un compte</h1>
<p v-if="note" class="text-xs text-muted mt-1">{{ note }}</p>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Identifiant</label>
<input v-model="username" type="text" placeholder="username" class="settings-input" @keyup.enter="submit" />
</div>
<div>
<label class="settings-label mb-1.5">Mot de passe</label>
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
</div>
<div>
<label class="settings-label mb-1.5">Confirmer</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
{{ submitting ? '…' : 'Créer le compte' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const code = route.params.code as string
type Status = 'loading' | 'ready' | 'invalid' | 'done'
const status = ref<Status>('loading')
const invalidReason = ref('')
const note = ref<string | null>(null)
const username = ref('')
const password = ref('')
const confirm = ref('')
const error = ref<string | null>(null)
const submitting = ref(false)
onMounted(async () => {
const res = await fetch(`/api/invites/${code}/info`)
const data = await res.json()
if (!res.ok || !data.valid) {
invalidReason.value = data.reason ?? 'Lien invalide'
status.value = 'invalid'
return
}
note.value = data.note
status.value = 'ready'
})
async function submit() {
error.value = null
if (!username.value || !password.value || !confirm.value) {
error.value = 'Tous les champs sont requis'; return
}
if (password.value !== confirm.value) {
error.value = 'Les mots de passe ne correspondent pas'; return
}
submitting.value = true
const res = await fetch(`/api/invites/${code}/register`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json()
submitting.value = false
if (!res.ok) { error.value = data.error; return }
status.value = 'done'
}
</script>
+259
View File
@@ -0,0 +1,259 @@
<template>
<div class="px-4 md:px-8 py-8 max-w-4xl">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-semibold text-primary">Demandes</h1>
<p class="text-sm text-muted mt-0.5">
{{ auth.isAdmin ? 'Toutes les demandes des utilisateurs.' : 'Vos demandes de séries.' }}
</p>
</div>
<!-- Filtres statuts -->
<div class="flex items-center gap-1">
<button
v-for="f in filters" :key="f.value"
@click="activeFilter = f.value"
class="px-3 py-1.5 rounded-lg text-xs transition"
:class="activeFilter === f.value
? 'bg-accent text-white'
: 'text-muted hover:text-primary hover:bg-hover'"
>
{{ f.label }}
<span v-if="counts[f.value]" class="ml-1 opacity-70">({{ counts[f.value] }})</span>
</button>
</div>
</div>
<!-- Statistiques (admin) -->
<div v-if="auth.isAdmin" class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">En attente</span>
<span class="text-2xl font-semibold text-yellow-400">{{ counts.pending }}</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Approuvées</span>
<span class="text-2xl font-semibold text-blue-400">{{ counts.approved }}</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Disponibles</span>
<span class="text-2xl font-semibold text-green-400">{{ counts.completed }}</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Refusées</span>
<span class="text-2xl font-semibold" :class="counts.rejected > 0 ? 'text-red-400' : 'text-muted'">{{ counts.rejected }}</span>
</div>
</div>
<!-- Liste -->
<div v-if="loading" class="flex items-center gap-2 text-muted text-sm py-8">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<div v-else-if="filtered.length === 0" class="settings-card text-sm text-muted py-8 text-center">
Aucune demande {{ activeFilter !== 'all' ? `avec ce statut` : '' }}.
</div>
<div v-else class="flex flex-col gap-3">
<div
v-for="req in filtered" :key="req.id"
class="settings-card flex flex-col gap-3"
>
<div class="flex items-start justify-between gap-4 flex-wrap">
<!-- Infos série -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<RouterLink :to="`/series/${req.serieId}`" class="text-sm font-semibold text-primary hover:text-accent transition truncate">
{{ req.serieName }}
</RouterLink>
<span class="text-xs px-2 py-0.5 rounded border shrink-0" :class="statusClass(req.status)">
{{ statusLabel(req.status) }}
</span>
</div>
<!-- Saisons fusionnées -->
<p class="text-xs text-muted mt-1">
<template v-if="mergedSeasons(req).length === 0">Toutes les saisons</template>
<template v-else>
Saisons : {{ mergedSeasons(req).map(s => s === 0 ? 'SP' : `S${s}`).join(', ') }}
</template>
</p>
<!-- Message de refus -->
<p v-if="req.rejectionMessage" class="text-xs text-red-400 mt-1">
Refus : {{ req.rejectionMessage }}
</p>
</div>
<!-- Actions admin -->
<div v-if="auth.isAdmin" class="flex items-center gap-1 shrink-0">
<button
v-if="req.status === 'pending' || req.status === 'rejected'"
@click="approve(req)"
class="btn-ghost text-xs text-blue-400 hover:text-blue-300"
>Approuver</button>
<button
v-if="req.status === 'approved'"
@click="complete(req)"
class="btn-ghost text-xs text-green-400 hover:text-green-300"
>Marquer disponible</button>
<button
v-if="req.status !== 'rejected'"
@click="openRejectModal(req)"
class="btn-ghost text-xs text-red-400 hover:text-red-300"
>Refuser</button>
<button @click="remove(req)" class="btn-ghost text-xs text-muted hover:text-red-400">Supprimer</button>
</div>
</div>
<!-- Requesters (admin) -->
<div v-if="auth.isAdmin && req.requesters.length > 0" class="flex flex-wrap gap-2">
<div v-for="r in req.requesters" :key="r.userId"
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-shell border border-border text-xs">
<span class="w-4 h-4 rounded-full bg-border flex items-center justify-center text-[9px] font-semibold text-muted shrink-0">
{{ r.username[0].toUpperCase() }}
</span>
<span class="text-muted">{{ r.username }}</span>
<span v-if="r.seasons.length > 0" class="text-muted/60">
({{ r.seasons.map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }})
</span>
</div>
</div>
<p class="text-[11px] text-muted">Demandé {{ formatRelative(req.createdAt) }}</p>
</div>
</div>
<!-- Modal refus -->
<Teleport to="body">
<div v-if="rejectModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="rejectModal = null">
<div class="settings-card w-full max-w-sm flex flex-col gap-4 p-6">
<p class="text-sm font-semibold text-primary">Refuser la demande</p>
<p class="text-xs text-muted truncate">{{ rejectModal.serieName }}</p>
<div>
<label class="settings-label mb-1.5">Message de refus (optionnel)</label>
<input v-model="rejectMessage" type="text" class="settings-input" placeholder="Raison du refus…" @keyup.enter="confirmReject" />
</div>
<div class="flex gap-2 justify-end">
<button @click="rejectModal = null" class="btn-ghost">Annuler</button>
<button @click="confirmReject" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition text-xs">
Refuser
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
interface Requester { userId: string; username: string; seasons: number[]; requestedAt: string }
interface SerieRequest {
id: string; serieId: number; serieName: string
requesters: Requester[]; status: string
rejectionMessage?: string; createdAt: string; updatedAt: string
}
const auth = useAuthStore()
const loading = ref(true)
const requests = ref<SerieRequest[]>([])
const activeFilter = ref<string>('all')
const filters = [
{ value: 'all', label: 'Toutes' },
{ value: 'pending', label: 'En attente' },
{ value: 'approved', label: 'Approuvées' },
{ value: 'completed', label: 'Disponibles' },
{ value: 'rejected', label: 'Refusées' },
]
const counts = computed(() => ({
all : requests.value.length,
pending : requests.value.filter(r => r.status === 'pending').length,
approved : requests.value.filter(r => r.status === 'approved').length,
completed: requests.value.filter(r => r.status === 'completed').length,
rejected : requests.value.filter(r => r.status === 'rejected').length,
}))
const filtered = computed(() =>
activeFilter.value === 'all'
? requests.value
: requests.value.filter(r => r.status === activeFilter.value)
)
async function load() {
loading.value = true
const res = await fetch('/api/requests', { credentials: 'include' })
if (res.ok) requests.value = await res.json()
loading.value = false
}
onMounted(load)
// ── Actions admin ─────────────────────────────────────────────
async function approve(req: SerieRequest) {
const res = await fetch(`/api/requests/${req.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ action: 'approve' }),
})
if (res.ok) load()
}
async function complete(req: SerieRequest) {
const res = await fetch(`/api/requests/${req.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ action: 'complete' }),
})
if (res.ok) load()
}
async function remove(req: SerieRequest) {
if (!confirm(`Supprimer la demande pour "${req.serieName}" ?`)) return
await fetch(`/api/requests/${req.id}`, { method: 'DELETE', credentials: 'include' })
load()
}
const rejectModal = ref<SerieRequest | null>(null)
const rejectMessage = ref('')
function openRejectModal(req: SerieRequest) { rejectModal.value = req; rejectMessage.value = '' }
async function confirmReject() {
if (!rejectModal.value) return
await fetch(`/api/requests/${rejectModal.value.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ action: 'reject', rejectionMessage: rejectMessage.value || undefined }),
})
rejectModal.value = null
load()
}
// ── Helpers ───────────────────────────────────────────────────
function mergedSeasons(req: SerieRequest): number[] {
if (req.requesters.some(r => r.seasons.length === 0)) return []
const all = new Set<number>()
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
return [...all].sort((a, b) => a - b)
}
function statusLabel(s: string) {
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[s] ?? s
}
function statusClass(s: string) {
return {
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
}[s] ?? ''
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
if (m < 1) return "à l'instant"
if (m < 60) return `il y a ${m}min`
if (h < 24) return `il y a ${h}h`
return `il y a ${d}j`
}
</script>
+147 -2
View File
@@ -17,8 +17,8 @@
<!-- Hero — poster, titre, métadonnées, synopsis -->
<SerieHero :serie="data.serie" />
<!-- Barre d'actions -->
<div class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<!-- Barre d'actions admin -->
<div v-if="auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<!-- Gauche : gestion bibliothèque -->
<div class="flex items-center gap-2">
@@ -103,6 +103,26 @@
</div>
</div>
<!-- Barre utilisateur (mode demande) -->
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<div v-if="myRequest" class="flex items-center gap-3">
<span class="text-xs px-2 py-1 rounded border" :class="requestStatusClass(myRequest.status)">
{{ requestStatusLabel(myRequest.status) }}
</span>
<span v-if="myRequest.status === 'rejected' && myRequest.rejectionMessage" class="text-xs text-muted">
{{ myRequest.rejectionMessage }}
</span>
</div>
<div v-else class="text-xs text-muted">Cliquez sur une saison ou un épisode pour faire une demande</div>
<button
v-if="!myRequest || myRequest.status === 'rejected' || myRequest.status === 'completed'"
@click="openRequestModal()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
>
Demander la série
</button>
</div>
<!-- Saisons -->
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4 pt-4">
<SerieSeasonCard
@@ -117,14 +137,55 @@
:ep-action-loading="epActionLoading"
:downloading-season="!!downloadingSeason[season.id]"
:nfo-support="nfoSupport"
:request-mode="!auth.isAdmin"
:requested-seasons="requestedSeasonNumbers"
@toggle="toggleSeason"
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
@download-season="(s, h) => downloadSeason(s, h)"
@rename-episode="(ep, s) => renameEpisode(ep, s)"
@unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)"
@unimport-season="(s, del) => unimportSeason(s, del)"
@request-season="handleRequestSeason"
@request-episode="handleRequestEpisode"
/>
</div>
<!-- Modal demande (utilisateurs) -->
<Teleport to="body">
<div v-if="requestModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="requestModal = false">
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
<div>
<p class="text-sm font-semibold text-primary">Demander une série</p>
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs text-muted font-medium">Saisons demandées</p>
<label
v-for="season in data?.seasons"
:key="season.id"
class="flex items-center gap-3 cursor-pointer select-none"
>
<input
type="checkbox"
:value="season.season_number"
v-model="requestSeasons"
class="w-4 h-4 rounded border-border accent-accent"
/>
<span class="text-sm text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal">— {{ season.title }}</span>
</span>
</label>
</div>
<div class="flex items-center justify-end gap-2">
<button @click="requestModal = false" class="btn-secondary text-xs">Annuler</button>
<button @click="submitRequest" :disabled="requestSeasons.length === 0" class="btn-primary text-xs disabled:opacity-50">
Envoyer la demande
</button>
</div>
</div>
</div>
</Teleport>
</template>
<!-- Modal désimport série -->
@@ -168,6 +229,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import ManualImportModal from '@/components/ManualImportModal.vue'
import SerieHero from '@/components/serie/SerieHero.vue'
@@ -176,8 +238,90 @@ import type { Season } from '@/stores/series'
const route = useRoute()
const store = useSeriesStore()
const auth = useAuthStore()
const { add: toast } = useToast()
// ── Mode demande (utilisateurs non-admin) ─────────────────────
const myRequest = ref<any>(null) // demande existante de l'utilisateur pour cette série
const requestModal = ref(false)
const requestSeasons = ref<number[]>([]) // saisons cochées dans le modal
const requestedSeasonNumbers = computed<number[]>(() => {
if (!myRequest.value) return []
// Trouver l'entrée de l'utilisateur connecté dans la demande fusionnée
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
// [] = toutes les saisons demandées → on retourne toutes les saisons de la série
if (r && r.seasons.length === 0) return data.value?.seasons?.map((s: any) => s.season_number) ?? []
return r?.seasons ?? []
})
async function fetchMyRequest() {
try {
const res = await fetch('/api/requests', { credentials: 'include' })
if (res.ok) {
const list = await res.json()
myRequest.value = list.find((r: any) =>
r.serieId === Number(route.params.id) &&
r.status !== 'rejected' && r.status !== 'completed'
) ?? null
}
} catch {}
}
function openRequestModal(preselectedSeason?: number) {
// Pré-cocher soit la saison cliquée, soit toutes les saisons disponibles
if (preselectedSeason !== undefined) {
requestSeasons.value = [preselectedSeason]
} else {
requestSeasons.value = data.value?.seasons?.map((s: any) => s.season_number) ?? []
}
requestModal.value = true
}
function handleRequestSeason(seasonNumber: number) {
openRequestModal(seasonNumber)
}
function handleRequestEpisode(seasonNumber: number, _episodeId: number) {
openRequestModal(seasonNumber)
}
async function submitRequest() {
if (!data.value) return
try {
const res = await fetch('/api/requests', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
serieId : Number(route.params.id),
serieName: data.value.serie.title,
seasons : requestSeasons.value,
}),
})
if (res.ok) {
myRequest.value = await res.json()
requestModal.value = false
toast('Demande envoyée ✓', 'success')
} else {
const d = await res.json()
toast(d.error ?? 'Erreur', 'error')
}
} catch { toast('Impossible de contacter le serveur', 'error') }
}
function requestStatusLabel(status: string) {
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[status] ?? status
}
function requestStatusClass(status: string) {
return {
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
}[status] ?? ''
}
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<string[]>([])
const downloaded = ref<string[]>([])
@@ -558,6 +702,7 @@ const closeMenus = () => { downloadMenuOpen.value = false }
onMounted(() => {
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads(); fetchRssSync()
if (!auth.isAdmin) fetchMyRequest()
pollTimer = setInterval(fetchActiveDownloads, 5000)
document.addEventListener('click', closeMenus)
})
+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>