Issues fixed: - #87: Extend video session cookie to 24h and add keep-alive refresh every 30 minutes during playback to prevent expiration while watching long movies - #88: Rank checklist completers by completion datetime (earliest first) in both the 100% tab and main stats ranking as tiebreaker - #89: Add confirmation modal when unchecking a movie from the checklist to prevent accidental removal Co-Authored-By: Claude Opus 4.5 <[email protected]>
701 lines
25 KiB
JavaScript
701 lines
25 KiB
JavaScript
const express = require("express");
|
|
const bcrypt = require("bcryptjs");
|
|
const jwt = require("jsonwebtoken");
|
|
const crypto = require("crypto");
|
|
const User = require("../models/User");
|
|
const Movie = require("../models/Movie");
|
|
const Setting = require("../models/Setting");
|
|
const PlaybackProgress = require("../models/PlaybackProgress");
|
|
const OscarPick = require("../models/OscarPick");
|
|
const OscarCategory = require("../models/OscarCategory");
|
|
const axios = require("axios");
|
|
|
|
const router = express.Router();
|
|
|
|
// Set password for verification
|
|
|
|
const woof =
|
|
typeof process.env.DOG_NAMES === "string"
|
|
? process.env.DOG_NAMES.split(",").map((s) => s.trim()).filter(Boolean)
|
|
: [];
|
|
|
|
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
|
|
|
// Fetch movie details from OMDb API
|
|
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
|
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
|
|
if (!apiKey) {
|
|
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
|
|
err.code = "OMDB_NOT_CONFIGURED";
|
|
throw err;
|
|
}
|
|
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
|
|
|
try {
|
|
const response = await axios.get(omdbUrl);
|
|
if (response.data.Response === "True") {
|
|
return {
|
|
title: response.data.Title.replace(/'/g, "")
|
|
};
|
|
} else {
|
|
throw new Error('Movie not found on OMDb API');
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Error fetching movie details: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Middleware to verify token
|
|
function verifyToken(req, res, next) {
|
|
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
|
|
? req.headers['authorization'].split(' ')[1]
|
|
: null;
|
|
|
|
if (!token) {
|
|
return res.status(403).json({ message: "No token provided, access denied." });
|
|
}
|
|
|
|
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
|
|
if (err) {
|
|
return res.status(401).json({ message: "Invalid or expired token" });
|
|
}
|
|
req.user = decoded; // Store decoded user info for use in routes
|
|
next();
|
|
});
|
|
}
|
|
|
|
function parseOscarYear(raw) {
|
|
if (raw === undefined || raw === null || raw === "") return null;
|
|
const n = Number(raw);
|
|
if (!Number.isInteger(n)) return null;
|
|
if (n < 1900 || n > 3000) return null;
|
|
return n;
|
|
}
|
|
|
|
function isAdminFromDecoded(decoded) {
|
|
return !!decoded?.admin;
|
|
}
|
|
|
|
function signUserToken(user) {
|
|
const isAdmin = user.role === 69;
|
|
const expiresIn = typeof process.env.JWT_EXPIRES_IN === "string" && process.env.JWT_EXPIRES_IN.trim()
|
|
? process.env.JWT_EXPIRES_IN.trim()
|
|
: "8h";
|
|
return jwt.sign(
|
|
{
|
|
id: user._id,
|
|
name: user.name,
|
|
admin: isAdmin,
|
|
mustChangePassword: !!user.mustChangePassword,
|
|
},
|
|
process.env.JWT_SECRET,
|
|
{ expiresIn }
|
|
);
|
|
}
|
|
|
|
function normalizeThemePreference(value) {
|
|
if (value === null || value === undefined) return null;
|
|
const raw = String(value || "").trim().toLowerCase();
|
|
if (raw === "light" || raw === "dark") return raw;
|
|
return null;
|
|
}
|
|
|
|
function generateTempPassword() {
|
|
// 12 chars, URL-safe (no spaces/specials that break copy/paste)
|
|
return crypto.randomBytes(9).toString("base64url");
|
|
}
|
|
|
|
const TEMP_PASSWORD_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
|
|
async function getOrInitActiveYear() {
|
|
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
|
|
if (existing && typeof existing.value === "number") return existing.value;
|
|
|
|
// Initialize from latest movie year if available, else from current year.
|
|
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
|
|
.sort({ year: -1 })
|
|
.select("year");
|
|
|
|
const fallbackYear = latestMovie?.year || new Date().getFullYear();
|
|
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
|
|
|
|
await Setting.findOneAndUpdate(
|
|
{ key: ACTIVE_YEAR_KEY },
|
|
{ $set: { value: year } },
|
|
{ upsert: true, new: true }
|
|
);
|
|
|
|
return year;
|
|
}
|
|
|
|
// Get user stats with watched movie titles and details from the movies collection
|
|
router.get("/stats", verifyToken, async (req, res) => {
|
|
try {
|
|
// Auth-scoped response: never allow caching.
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
|
|
// Fetch all users (only needed fields)
|
|
const users = await User.find().select("name watchedMovies").lean();
|
|
|
|
// If a year is explicitly provided, use it; otherwise default to the global active year.
|
|
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
|
|
|
// Fetch movies (optionally filtered by year)
|
|
const allMovies = await Movie.find({ year })
|
|
.select("imdb_id title poster rating category year vod_link player_mode video_src embed_src video_file")
|
|
.lean();
|
|
const totalMoviesCount = allMovies.length;
|
|
|
|
if (totalMoviesCount === 0) {
|
|
return res.status(400).json({ message: "Aucun films disponible." });
|
|
}
|
|
|
|
// Get points configuration
|
|
const POINTS_CONFIG_KEY = "points_config";
|
|
const pointsConfigSetting = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value").lean();
|
|
const pointsConfig = pointsConfigSetting?.value && typeof pointsConfigSetting.value === "object"
|
|
? {
|
|
pointsPerMovie: typeof pointsConfigSetting.value.pointsPerMovie === "number" ? pointsConfigSetting.value.pointsPerMovie : 1,
|
|
pointsPerCorrectPick: typeof pointsConfigSetting.value.pointsPerCorrectPick === "number" ? pointsConfigSetting.value.pointsPerCorrectPick : 1,
|
|
}
|
|
: { pointsPerMovie: 1, pointsPerCorrectPick: 1 };
|
|
|
|
// Get all categories with winners marked for this year
|
|
const categories = await OscarCategory.find({ year }).lean();
|
|
const categoryWinners = new Map();
|
|
categories.forEach(cat => {
|
|
const winner = cat.nominees.find(n => n.isWinner);
|
|
if (winner) {
|
|
categoryWinners.set(cat.categoryNumber, winner.name);
|
|
}
|
|
});
|
|
|
|
// Get all picks for this year and calculate correct picks on-the-fly
|
|
let picksByUserId = new Map();
|
|
try {
|
|
const allPicks = await OscarPick.find({ year })
|
|
.select("userId picks")
|
|
.lean();
|
|
|
|
allPicks.forEach(pick => {
|
|
const userId = String(pick.userId);
|
|
let correctCount = 0;
|
|
|
|
// Calculate correct picks based on current winners
|
|
if (Array.isArray(pick.picks)) {
|
|
pick.picks.forEach(p => {
|
|
const correctWinner = categoryWinners.get(p.categoryNumber);
|
|
if (correctWinner && p.selectedNominee === correctWinner) {
|
|
correctCount++;
|
|
}
|
|
});
|
|
}
|
|
|
|
picksByUserId.set(userId, correctCount);
|
|
});
|
|
} catch (err) {
|
|
console.error("Error fetching picks for stats:", err);
|
|
// Continue without picks - users will just have 0 pick points
|
|
}
|
|
|
|
// Build stats for each user
|
|
const userStats = users.map((user) => {
|
|
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
|
|
|
|
// Join watched movies with movie details for the selected year (keep watchedDate)
|
|
const watchedRaw = Array.isArray(user?.watchedMovies) ? user.watchedMovies : [];
|
|
const watchedMoviesDetails = watchedRaw
|
|
.map((wm) => {
|
|
const imdbId = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
|
|
if (!imdbId) return null;
|
|
const movie = movieByImdbId.get(imdbId);
|
|
if (!movie) return null;
|
|
return {
|
|
movieId: movie?._id ? String(movie._id) : null,
|
|
imdb_id: movie.imdb_id,
|
|
title: movie.title || "",
|
|
poster: movie.poster || "",
|
|
rating: movie.rating || "",
|
|
category: movie.category || "",
|
|
year: Number.isInteger(movie.year) ? movie.year : year,
|
|
vod_link: movie.vod_link || "",
|
|
player_mode: movie.player_mode || "auto",
|
|
video_src: movie.video_src || "",
|
|
embed_src: movie.embed_src || "",
|
|
video_file: movie.video_file || "",
|
|
watchedDate: wm?.watchedDate ? new Date(wm.watchedDate).toISOString() : null,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const watchedCount = watchedMoviesDetails.length;
|
|
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
|
|
|
|
// Calculate the last watched date (used for ranking tiebreakers - earlier completion = better)
|
|
let lastWatchedAt = null;
|
|
watchedMoviesDetails.forEach((wm) => {
|
|
if (wm.watchedDate) {
|
|
const d = new Date(wm.watchedDate);
|
|
if (!lastWatchedAt || d > lastWatchedAt) {
|
|
lastWatchedAt = d;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Calculate points
|
|
const userId = String(user._id);
|
|
const correctPicks = picksByUserId.get(userId) || 0;
|
|
const moviePoints = watchedCount * pointsConfig.pointsPerMovie;
|
|
const pickPoints = correctPicks * pointsConfig.pointsPerCorrectPick;
|
|
const totalPoints = moviePoints + pickPoints;
|
|
|
|
return {
|
|
name: user.name,
|
|
userId: userId,
|
|
year,
|
|
watchedCount,
|
|
totalMoviesCount,
|
|
watchedRatio: `${watchedRatio}%`,
|
|
watchedMovies: watchedMoviesDetails, // Include movie details
|
|
correctPicks: correctPicks,
|
|
moviePoints: moviePoints,
|
|
pickPoints: pickPoints,
|
|
totalPoints: totalPoints,
|
|
pointsConfig: pointsConfig,
|
|
lastWatchedAt: lastWatchedAt ? lastWatchedAt.toISOString() : null,
|
|
};
|
|
});
|
|
|
|
res.json(userStats);
|
|
} catch (err) {
|
|
console.error("Erreur dans l'extraction des statistiques sur les utilisateurs :", err);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
// Get users who have 100% completion per year (across all years in DB)
|
|
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
|
|
router.get("/completions", verifyToken, async (req, res) => {
|
|
try {
|
|
// Auth-scoped response: never allow caching.
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
|
|
const yearsRaw = await Movie.distinct("year");
|
|
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
|
|
.map((y) => Number(y))
|
|
.filter((y) => parseOscarYear(y))
|
|
.sort((a, b) => b - a);
|
|
|
|
if (years.length === 0) {
|
|
return res.status(200).json({ years: [], totals: {}, completersByYear: {} });
|
|
}
|
|
|
|
// Fetch imdb_id + year for all movies that have a valid year
|
|
const movies = await Movie.find({ year: { $in: years } }).select("imdb_id year").lean();
|
|
|
|
const imdbIdsByYear = new Map();
|
|
const totals = {};
|
|
|
|
movies.forEach((m) => {
|
|
const year = Number(m?.year);
|
|
if (!parseOscarYear(year)) return;
|
|
const imdb = typeof m?.imdb_id === "string" ? m.imdb_id : "";
|
|
if (!imdb) return;
|
|
if (!imdbIdsByYear.has(year)) imdbIdsByYear.set(year, new Set());
|
|
imdbIdsByYear.get(year).add(imdb);
|
|
});
|
|
|
|
for (const y of years) {
|
|
const set = imdbIdsByYear.get(y);
|
|
totals[String(y)] = set ? set.size : 0;
|
|
}
|
|
|
|
const users = await User.find().select("name watchedMovies.imdb_id watchedMovies.watchedDate").lean();
|
|
|
|
const completersByYear = {};
|
|
for (const y of years) {
|
|
const total = totals[String(y)] || 0;
|
|
if (total <= 0) {
|
|
completersByYear[String(y)] = [];
|
|
continue;
|
|
}
|
|
const yearSet = imdbIdsByYear.get(y) || new Set();
|
|
|
|
const completers = [];
|
|
users.forEach((u) => {
|
|
const watched = Array.isArray(u?.watchedMovies) ? u.watchedMovies : [];
|
|
|
|
// Build a map of imdb_id -> watchedDate for this user's movies in this year
|
|
const watchedInYear = [];
|
|
watched.forEach((wm) => {
|
|
const imdb = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
|
|
if (!imdb || !yearSet.has(imdb)) return;
|
|
watchedInYear.push({
|
|
imdb_id: imdb,
|
|
watchedDate: wm?.watchedDate ? new Date(wm.watchedDate) : null,
|
|
});
|
|
});
|
|
|
|
// Count unique movies watched in this year
|
|
const uniqueImdbIds = new Set(watchedInYear.map((w) => w.imdb_id));
|
|
const count = uniqueImdbIds.size;
|
|
|
|
if (count === total) {
|
|
// Find the completion date (the latest watchedDate among movies for this year)
|
|
// This is when the user completed 100% of the checklist
|
|
let completedAt = null;
|
|
watchedInYear.forEach((w) => {
|
|
if (w.watchedDate && (!completedAt || w.watchedDate > completedAt)) {
|
|
completedAt = w.watchedDate;
|
|
}
|
|
});
|
|
|
|
completers.push({
|
|
name: u?.name || "(sans nom)",
|
|
watchedCount: count,
|
|
totalMoviesCount: total,
|
|
completedAt: completedAt ? completedAt.toISOString() : null,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Sort by completion date (earliest first = higher rank), then alphabetically for ties
|
|
completers.sort((a, b) => {
|
|
// Users with a completion date come before those without
|
|
if (a.completedAt && !b.completedAt) return -1;
|
|
if (!a.completedAt && b.completedAt) return 1;
|
|
// Both have dates: earliest first
|
|
if (a.completedAt && b.completedAt) {
|
|
const dateA = new Date(a.completedAt).getTime();
|
|
const dateB = new Date(b.completedAt).getTime();
|
|
if (dateA !== dateB) return dateA - dateB;
|
|
}
|
|
// Tie-breaker: alphabetical
|
|
return String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" });
|
|
});
|
|
completersByYear[String(y)] = completers;
|
|
}
|
|
|
|
return res.status(200).json({ years, totals, completersByYear });
|
|
} catch (err) {
|
|
console.error("Erreur dans l'extraction des completers:", err);
|
|
return res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
});
|
|
|
|
// Register User
|
|
router.post("/register", async (req, res) => {
|
|
const { name, email, password, verifoof } = req.body;
|
|
const dogCheckEnabled = woof.length > 0;
|
|
|
|
function registerVerification() {
|
|
// If DOG_NAMES isn't configured, skip the "dog name" verification entirely.
|
|
if (!dogCheckEnabled) return true;
|
|
return woof.includes(verifoof);
|
|
}
|
|
|
|
try {
|
|
const nameNorm = typeof name === "string" ? name.trim() : "";
|
|
const emailNorm = typeof email === "string" ? email.trim() : "";
|
|
|
|
if (!nameNorm) {
|
|
throw new Error("Le nom est obligatoire");
|
|
}
|
|
if (!emailNorm) {
|
|
throw new Error("L'email est obligatoire");
|
|
}
|
|
// Basic sanity check (not a full RFC validator)
|
|
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
|
|
throw new Error("Email invalide.");
|
|
}
|
|
if (!password) {
|
|
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
|
}
|
|
if (dogCheckEnabled && !verifoof) {
|
|
throw new Error("Le nom du chien doit être défini");
|
|
}
|
|
if (!registerVerification()) {
|
|
return res.status(400).json({ message: "Mauvais nom de chien" });
|
|
}
|
|
|
|
const existingUser = await User.findOne({ email: emailNorm });
|
|
if (existingUser) {
|
|
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
|
}
|
|
|
|
// Hash password and create user
|
|
const hashedPassword = await bcrypt.hash(password, 10);
|
|
// SECURITY: never accept role escalation from a public endpoint.
|
|
const user = new User({ name: nameNorm, email: emailNorm, password: hashedPassword, role: 0 });
|
|
await user.save();
|
|
|
|
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
|
} catch (err) {
|
|
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
|
}
|
|
});
|
|
|
|
// Login User
|
|
router.post("/login", async (req, res) => {
|
|
const { email, password } = req.body;
|
|
|
|
try {
|
|
const user = await User.findOne({ email });
|
|
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" });
|
|
|
|
// If this account is in "temp password" mode and the temp password has expired, block login.
|
|
if (user.mustChangePassword && user.tempPasswordExpiresAt && user.tempPasswordExpiresAt instanceof Date) {
|
|
if (Date.now() > user.tempPasswordExpiresAt.getTime()) {
|
|
return res.status(401).json({
|
|
message: "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.",
|
|
});
|
|
}
|
|
}
|
|
|
|
const isMatch = await bcrypt.compare(password, user.password);
|
|
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
|
|
|
|
console.log("[debug] " + user.name + " logged in")
|
|
|
|
const token = signUserToken(user);
|
|
res.status(200).json({ token, mustChangePassword: !!user.mustChangePassword });
|
|
} catch (err) {
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// User: get theme preference
|
|
router.get("/theme", verifyToken, async (req, res) => {
|
|
try {
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
|
|
const user = await User.findById(req.user.id).select("themePreference").lean();
|
|
if (!user) return res.status(404).json({ message: "User not found" });
|
|
return res.status(200).json({ themePreference: user.themePreference || null });
|
|
} catch (err) {
|
|
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
|
}
|
|
});
|
|
|
|
// User: update theme preference (light/dark or null for system)
|
|
router.put("/theme", verifyToken, async (req, res) => {
|
|
try {
|
|
const preference = normalizeThemePreference(req.body?.themePreference);
|
|
const provided = req.body?.themePreference;
|
|
if (provided !== null && provided !== undefined && preference === null) {
|
|
return res.status(400).json({ message: "Préférence invalide (light/dark/null)." });
|
|
}
|
|
|
|
const update = preference ? { $set: { themePreference: preference } } : { $unset: { themePreference: 1 } };
|
|
|
|
const user = await User.findByIdAndUpdate(req.user.id, update, { new: true, select: "themePreference" }).lean();
|
|
|
|
if (!user) return res.status(404).json({ message: "User not found" });
|
|
return res.status(200).json({ themePreference: user.themePreference || null });
|
|
} catch (err) {
|
|
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
|
}
|
|
});
|
|
|
|
// Admin: reset a user's password to a random temp password and force change on next login.
|
|
router.post("/admin/reset-temp-password", verifyToken, async (req, res) => {
|
|
if (!isAdminFromDecoded(req.user)) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
|
|
const { email, userId } = req.body || {};
|
|
const emailNorm = typeof email === "string" ? email.trim() : "";
|
|
const idNorm = typeof userId === "string" ? userId.trim() : "";
|
|
|
|
if (!emailNorm && !idNorm) {
|
|
return res.status(400).json({ message: "Email ou userId requis." });
|
|
}
|
|
|
|
try {
|
|
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
|
|
if (!user) return res.status(404).json({ message: "User not found" });
|
|
|
|
const tempPassword = generateTempPassword();
|
|
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
|
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
|
|
|
|
user.password = hashedPassword;
|
|
user.mustChangePassword = true;
|
|
user.tempPasswordIssuedAt = new Date();
|
|
user.tempPasswordExpiresAt = expiresAt;
|
|
await user.save();
|
|
|
|
return res.status(200).json({
|
|
message: "Mot de passe temporaire généré. L'utilisateur devra le changer à sa prochaine connexion.",
|
|
tempPassword,
|
|
expiresAt: expiresAt.toISOString(),
|
|
user: { id: String(user._id), name: user.name, email: user.email },
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Admin: list users (safe fields only)
|
|
router.get("/admin/list", verifyToken, async (req, res) => {
|
|
if (!isAdminFromDecoded(req.user)) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
|
|
try {
|
|
// Admin + auth-scoped data: never allow caching.
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
|
|
const users = await User.find()
|
|
.select("name email role mustChangePassword")
|
|
.sort({ name: 1, email: 1 });
|
|
|
|
return res.status(200).json(
|
|
users.map((u) => ({
|
|
id: String(u._id),
|
|
name: u.name,
|
|
email: u.email,
|
|
admin: u.role === 69,
|
|
mustChangePassword: !!u.mustChangePassword,
|
|
}))
|
|
);
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Admin: create a user with a random temp password (forces change on first login).
|
|
router.post("/admin/create", verifyToken, async (req, res) => {
|
|
if (!isAdminFromDecoded(req.user)) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
|
|
const { name, email, admin } = req.body || {};
|
|
const nameNorm = typeof name === "string" ? name.trim() : "";
|
|
const emailNorm = typeof email === "string" ? email.trim() : "";
|
|
const makeAdmin = !!admin;
|
|
|
|
if (!nameNorm) {
|
|
return res.status(400).json({ message: "Nom requis." });
|
|
}
|
|
if (!emailNorm) {
|
|
return res.status(400).json({ message: "Email requis." });
|
|
}
|
|
// Basic sanity check (not a full RFC validator)
|
|
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
|
|
return res.status(400).json({ message: "Email invalide." });
|
|
}
|
|
|
|
try {
|
|
const existingUser = await User.findOne({ email: emailNorm });
|
|
if (existingUser) {
|
|
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
|
}
|
|
|
|
const tempPassword = generateTempPassword();
|
|
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
|
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
|
|
|
|
const user = new User({
|
|
name: nameNorm,
|
|
email: emailNorm,
|
|
password: hashedPassword,
|
|
role: makeAdmin ? 69 : 0,
|
|
mustChangePassword: true,
|
|
tempPasswordIssuedAt: new Date(),
|
|
tempPasswordExpiresAt: expiresAt,
|
|
});
|
|
|
|
await user.save();
|
|
|
|
return res.status(201).json({
|
|
message: "Utilisateur créé. Mot de passe temporaire généré.",
|
|
tempPassword,
|
|
expiresAt: expiresAt.toISOString(),
|
|
user: { id: String(user._id), name: user.name, email: user.email, admin: user.role === 69, mustChangePassword: true },
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Admin: delete a user (and related playback progress)
|
|
router.post("/admin/delete", verifyToken, async (req, res) => {
|
|
if (!isAdminFromDecoded(req.user)) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
|
|
const { email, userId } = req.body || {};
|
|
const emailNorm = typeof email === "string" ? email.trim() : "";
|
|
const idNorm = typeof userId === "string" ? userId.trim() : "";
|
|
|
|
if (!emailNorm && !idNorm) {
|
|
return res.status(400).json({ message: "Email ou userId requis." });
|
|
}
|
|
|
|
try {
|
|
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
|
|
if (!user) return res.status(404).json({ message: "User not found" });
|
|
|
|
// Safety: prevent deleting yourself from the admin UI.
|
|
if (String(user._id) === String(req.user?.id || "")) {
|
|
return res.status(400).json({ message: "Impossible de supprimer ton propre compte." });
|
|
}
|
|
|
|
// Safety: prevent deleting admin accounts via the UI endpoint.
|
|
if (user.role === 69) {
|
|
return res.status(400).json({ message: "Impossible de supprimer un compte admin via cette interface." });
|
|
}
|
|
|
|
await PlaybackProgress.deleteMany({ userId: user._id });
|
|
await User.deleteOne({ _id: user._id });
|
|
|
|
return res.status(200).json({
|
|
message: "Utilisateur supprimé.",
|
|
user: { id: String(user._id), name: user.name, email: user.email },
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// User: change password (used after temp-password login). Returns a fresh token.
|
|
router.post("/change-password", verifyToken, async (req, res) => {
|
|
const { newPassword } = req.body || {};
|
|
const pw = typeof newPassword === "string" ? newPassword : "";
|
|
|
|
if (!pw || pw.length < 8) {
|
|
return res.status(400).json({ message: "Le nouveau mot de passe doit contenir au moins 8 caractères." });
|
|
}
|
|
|
|
try {
|
|
const user = await User.findById(req.user?.id);
|
|
if (!user) return res.status(404).json({ message: "User not found" });
|
|
|
|
const hashedPassword = await bcrypt.hash(pw, 10);
|
|
user.password = hashedPassword;
|
|
user.mustChangePassword = false;
|
|
user.tempPasswordIssuedAt = null;
|
|
user.tempPasswordExpiresAt = null;
|
|
await user.save();
|
|
|
|
const token = signUserToken(user);
|
|
return res.status(200).json({ message: "Mot de passe mis à jour.", token });
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Protected route to get movies (example)
|
|
router.get("/movies", verifyToken, async (req, res) => {
|
|
// Only authorized users will reach this point
|
|
const movies = await Movie.find(); // Assuming Movie model exists
|
|
res.json(movies);
|
|
});
|
|
|
|
module.exports = router;
|