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