BREAKING CHANGE: nouvelle architecture frontend avec layout sidebar, système de thèmes et routes restructurées ### Frontend - nouveau layout global `AppLayout.vue` avec sidebar de navigation inspirée Radarr/Sonarr - sidebar configurable via un objet `NavItem[]` avec support parents/enfants cliquables - 3 thèmes CSS (Indigo Électrique, Crimson Night, Teal Froid) persistés via localStorage - switcher de thème en bas de sidebar avec pastilles colorées - logo FanKarr cliquable, bouton déconnexion discret dans le header sidebar - badge animé sur "Activité" affichant le nombre de téléchargements en cours - migration complète vers Tailwind v4 avec variables CSS `@theme` - classes utilitaires globales : `btn-primary`, `btn-secondary`, `settings-card`, `settings-input` ### Vues - `SeriesView` : barre colorée en bas des affiches (vert/bleu/orange), dropdown filtres, boutons taille S/M/L/XL persistés - `SerieView` : bouton état unique par épisode (↓/⟳/✓/✗), badges simplifiés, toast via `useToast` global - `ActivityView` : onglets pill, stats, toggle "masquer importés", polling silencieux - `DashboardView` : stats catalogue/importées/en cours/erreurs, torrents actifs, derniers imports - `SettingsView` découpée en 3 vues indépendantes : `DownloadClientView`, `MediaManagementView`, `CatalogueView` - `LogsView` déplacée dans `src/views/settings/` - `AuthView` : migré au système de thème ### Composants - `SidebarNavItem` : support badge, chevron masqué si badge présent - `SettingsToggle` : composant réutilisable `v-model` - `FolderPicker` : migré au thème, `Teleport` intégré - `ToastContainer` : déplacé en bas droite, groupement des toasts similaires avec badge count ### Router - `AppLayout` comme layout racine pour toutes les routes protégées - `/auth` reste hors layout - redirections `/` → `/series`, `/downloads` → `/activity` - `SettingsLayout` avec titre dynamique par route - scroll géré sur `<main id="main-scroll">` dans `AppLayout` ### Stores & composables - nouveau store `downloads` partagé pour le badge sidebar - `usePosterSize` : taille des affiches persistée en localStorage - `useToast` : groupement des toasts du même type, timer reset au lieu d'empilement ### Serveur - logs structurés via `logger` dans `auth.ts`, `organize.ts`, `organize-worker.ts`, `torrent-clients/index.ts`, `qbittorrent.ts`, `index.ts` - suppression des `console.log/error/warn` en faveur de `logger.info/warn/error/debug` - messages worker routés vers `logger` via `organize.ts` - logs `debug` pour les événements verbeux (healthcheck, skip, worker terminé)
128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import bcrypt from 'bcryptjs'
|
|
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'
|
|
|
|
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))
|
|
}
|
|
|
|
// ── Routes ────────────────────────────────────────────────────
|
|
|
|
// GET /api/auth/status
|
|
export function authStatus(req: Request, res: Response): void {
|
|
const existing = readAuth()
|
|
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é')
|
|
}
|
|
}
|
|
|
|
res.json({ setup: !!existing, loggedIn })
|
|
}
|
|
|
|
// POST /api/auth/setup
|
|
export function authSetup(req: Request, res: Response): void {
|
|
if (readAuth()) {
|
|
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
|
|
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
|
|
}
|
|
|
|
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
|
writeAuth({ username, passwordHash })
|
|
logger.info('auth', `Compte créé pour "${username}"`)
|
|
|
|
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
|
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
|
res.json({ success: true })
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
const { username, password } = req.body
|
|
if (!username || !password) {
|
|
res.status(400).json({ error: 'Username et password requis' })
|
|
return
|
|
}
|
|
|
|
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
|
|
logger.warn('auth', `Échec de connexion pour "${username}"`)
|
|
res.status(401).json({ error: 'Identifiants incorrects' })
|
|
return
|
|
}
|
|
|
|
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
|
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
|
logger.info('auth', `Connexion réussie pour "${username}"`)
|
|
res.json({ success: true })
|
|
}
|
|
|
|
// POST /api/auth/logout
|
|
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}"`)
|
|
} catch {}
|
|
}
|
|
res.clearCookie('fankarr_token')
|
|
res.json({ success: true })
|
|
}
|
|
|
|
// Middleware protection des routes
|
|
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
|
|
}
|
|
try {
|
|
jwt.verify(token, JWT_SECRET)
|
|
next()
|
|
} catch {
|
|
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
|
|
res.status(401).json({ error: 'Token invalide' })
|
|
}
|
|
} |