add video backend server, fix styling and more #54

Merged
quoije merged 131 commits from 2026-video.backend into prod 2025-12-22 05:42:04 -05:00
11 changed files with 386 additions and 18 deletions
Showing only changes of commit ff289c38da - Show all commits
+4
View File
@@ -5,6 +5,10 @@ const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true }, email: { type: String, required: true, unique: true },
password: { type: String, required: true }, password: { type: String, required: true },
role: { type: Number, default: 0 }, role: { type: Number, default: 0 },
// If true, user must change password on next login (e.g. after admin reset)
mustChangePassword: { type: Boolean, default: false },
tempPasswordIssuedAt: { type: Date, default: null },
tempPasswordExpiresAt: { type: Date, default: null },
watchedMovies: [{ watchedMovies: [{
imdb_id: { type: String, required: false }, imdb_id: { type: String, required: false },
watchedDate: { type: Date, default: Date.now } // Store the watched date watchedDate: { type: Date, default: Date.now } // Store the watched date
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Changer le mot de passe</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container d-flex justify-content-center align-items-center vh-100">
<div class="card p-4" style="max-width: 420px; width: 100%;">
<div class="text-center mb-3">
<img src="img/logo.png" style="width: 80%;">
</div>
<div class="alert alert-warning" role="alert">
Votre compte utilise un <strong>mot de passe temporaire</strong>. Veuillez définir un nouveau mot de passe pour continuer.
</div>
<div id="change-password-error" class="alert alert-danger d-none" role="alert"></div>
<form id="change-password-form">
<div class="mb-3">
<label for="new-password" class="form-label">Nouveau mot de passe</label>
<input type="password" class="form-control" id="new-password" minlength="8" required>
<div class="form-text">Minimum 8 caractères.</div>
</div>
<div class="mb-3">
<label for="confirm-password" class="form-label">Confirmer le mot de passe</label>
<input type="password" class="form-control" id="confirm-password" minlength="8" required>
</div>
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
</form>
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3">Déconnexion</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_change-password.js"></script>
</body>
</html>
+24
View File
@@ -70,6 +70,30 @@
</div> </div>
</div> </div>
<h2>Admin - Réinitialiser mot de passe utilisateur</h2>
<p class="text-muted mb-3">
Génère un mot de passe temporaire aléatoire. L'utilisateur devra choisir un nouveau mot de passe à sa prochaine connexion.
</p>
<div id="user-reset-response" class="alert d-none" role="alert"></div>
<form id="reset-user-password-form" class="card p-3 mb-4">
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label for="reset_user_email" class="form-label">Email utilisateur</label>
<input type="email" class="form-control" id="reset_user_email" placeholder="[email protected]" required>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-danger w-100">Reset</button>
</div>
<div class="col-12">
<div class="form-text">
Important: le mot de passe temporaire est affiché une seule fois ici. Copie-le et envoie-le à l'utilisateur.
</div>
</div>
</div>
</form>
<h2>Admin - Ajouter un film</h2> <h2>Admin - Ajouter un film</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
Ajoute un film via l’API (<code>/api/movies/add</code>). Le serveur récupère automatiquement le titre/plot/affiche depuis OMDb avec l’<code>imdb_id</code>. Ajoute un film via l’API (<code>/api/movies/add</code>). Le serveur récupère automatiquement le titre/plot/affiche depuis OMDb avec l’<code>imdb_id</code>.
+103
View File
@@ -0,0 +1,103 @@
function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
function isTokenExpired(decoded) {
const currentTime = Math.floor(Date.now() / 1000);
return !!decoded.exp && decoded.exp < currentTime;
}
window.onload = function () {
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
let decoded;
try {
decoded = decodeJwt(token);
} catch (e) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (isTokenExpired(decoded)) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Only show this page if user must change password.
if (!decoded.mustChangePassword) {
window.location.href = '/movies.html';
return;
}
const errorEl = document.getElementById('change-password-error');
const form = document.getElementById('change-password-form');
function showError(message) {
errorEl.classList.remove('d-none');
errorEl.textContent = message;
}
function clearError() {
errorEl.classList.add('d-none');
errorEl.textContent = '';
}
document.getElementById('log-off').addEventListener('click', function () {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
form.addEventListener('submit', async function (e) {
e.preventDefault();
clearError();
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
if (newPassword.length < 8) {
showError('Le mot de passe doit contenir au moins 8 caractères.');
return;
}
if (newPassword !== confirmPassword) {
showError('Les mots de passe ne correspondent pas.');
return;
}
try {
const res = await fetch('/api/users/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ newPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.message || data.error || `Erreur (${res.status})`);
return;
}
if (data.token) {
localStorage.setItem('auth_token', data.token);
} else {
// Fallback: force re-login if no token is returned.
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
window.location.href = '/movies.html';
} catch (err) {
showError(err.message || 'Erreur réseau');
}
});
};
+4
View File
@@ -27,6 +27,10 @@ window.onload = async function () {
if (token) { if (token) {
try { try {
const decoded = JSON.parse(atob(token.split('.')[1])); const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name; const userName = decoded.name;
document.getElementById('user-name').textContent = userName; document.getElementById('user-name').textContent = userName;
if (decoded.admin) { if (decoded.admin) {
+51
View File
@@ -21,6 +21,11 @@ window.onload = async function () {
return; return;
} }
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
document.getElementById('user-name').textContent = decoded.name || ''; document.getElementById('user-name').textContent = decoded.name || '';
// Admin-only page: if token doesn't contain admin flag, bounce. // Admin-only page: if token doesn't contain admin flag, bounce.
@@ -68,6 +73,17 @@ window.onload = async function () {
responseEl.textContent = message; responseEl.textContent = message;
} }
const userResetResponseEl = document.getElementById('user-reset-response');
const userResetForm = document.getElementById('reset-user-password-form');
const userResetEmailEl = document.getElementById('reset_user_email');
function showUserResetResponse(kind, message) {
if (!userResetResponseEl) return;
userResetResponseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
userResetResponseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
userResetResponseEl.textContent = message;
}
function parseYear(value) { function parseYear(value) {
const n = Number(value); const n = Number(value);
if (!Number.isInteger(n)) return null; if (!Number.isInteger(n)) return null;
@@ -123,6 +139,41 @@ window.onload = async function () {
window.location.href = '/'; window.location.href = '/';
}); });
if (userResetForm && userResetEmailEl) {
userResetForm.addEventListener('submit', async function (e) {
e.preventDefault();
const email = (userResetEmailEl.value || '').trim();
if (!email) {
showUserResetResponse('warning', 'Email invalide.');
return;
}
try {
showUserResetResponse('warning', 'Génération du mot de passe temporaire...');
const res = await fetch('/api/users/admin/reset-temp-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ email }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showUserResetResponse('danger', data.message || data.error || `Erreur (${res.status})`);
return;
}
const label = data?.user?.name ? `${data.user.name} (${data.user.email})` : email;
const expiresAt = data?.expiresAt ? ` Expire: ${data.expiresAt}` : '';
showUserResetResponse('success', `Temp password pour ${label}: ${data.tempPassword}.${expiresAt}`);
} catch (err) {
showUserResetResponse('danger', err.message || 'Erreur réseau');
}
});
}
form.addEventListener('submit', async function (e) { form.addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
+23 -2
View File
@@ -1,8 +1,24 @@
function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
// Check if the user is already logged in by looking for auth_token in localStorage // Check if the user is already logged in by looking for auth_token in localStorage
if (localStorage.getItem('auth_token')) { const existingToken = localStorage.getItem('auth_token');
// If the token is found, redirect to /movies.html if (existingToken) {
try {
const decoded = decodeJwt(existingToken);
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
} else if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
} else {
window.location.href = '/movies.html'; window.location.href = '/movies.html';
} }
} catch (_) {
localStorage.removeItem('auth_token');
}
}
// Handle login form submission // Handle login form submission
document.getElementById('login-form').addEventListener('submit', async function (e) { document.getElementById('login-form').addEventListener('submit', async function (e) {
@@ -21,7 +37,12 @@ if (localStorage.getItem('auth_token')) {
const data = await res.json(); const data = await res.json();
// Save the token to localStorage // Save the token to localStorage
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
if (data.mustChangePassword) {
window.location.href = '/change-password.html';
} else {
window.location.href = '/movies.html'; // Redirect to movies page on success window.location.href = '/movies.html'; // Redirect to movies page on success
}
} else { } else {
const error = await res.json(); const error = await res.json();
alert(`${error.message}`); alert(`${error.message}`);
+4
View File
@@ -50,6 +50,10 @@ window.onload = async function () {
if (token) { if (token) {
try { try {
const decoded = JSON.parse(atob(token.split('.')[1])); const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name; const userName = decoded.name;
document.getElementById('user-name').textContent = userName; document.getElementById('user-name').textContent = userName;
if (decoded.admin) { if (decoded.admin) {
+18 -2
View File
@@ -1,8 +1,24 @@
function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
// Check if the user is already logged in by looking for auth_token in localStorage // Check if the user is already logged in by looking for auth_token in localStorage
if (localStorage.getItem('auth_token')) { const existingToken = localStorage.getItem('auth_token');
// If the token is found, redirect to /movies.html if (existingToken) {
try {
const decoded = decodeJwt(existingToken);
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
} else if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
} else {
window.location.href = '/movies.html'; window.location.href = '/movies.html';
} }
} catch (_) {
localStorage.removeItem('auth_token');
}
}
// Handle registration form submission // Handle registration form submission
document.getElementById('register-form').addEventListener('submit', async function (e) { document.getElementById('register-form').addEventListener('submit', async function (e) {
+4
View File
@@ -16,6 +16,10 @@ window.onload = async function () {
if (token) { if (token) {
try { try {
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name; const userName = decoded.name;
document.getElementById('user-name').textContent = userName; document.getElementById('user-name').textContent = userName;
if (decoded.admin) { if (decoded.admin) {
+103 -11
View File
@@ -1,6 +1,7 @@
const express = require("express"); const express = require("express");
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const User = require("../models/User"); const User = require("../models/User");
const Movie = require("../models/Movie"); const Movie = require("../models/Movie");
const Setting = require("../models/Setting"); const Setting = require("../models/Setting");
@@ -60,6 +61,31 @@ function parseOscarYear(raw) {
return n; return n;
} }
function isAdminFromDecoded(decoded) {
return !!decoded?.admin;
}
function signUserToken(user) {
const isAdmin = user.role === 69;
return jwt.sign(
{
id: user._id,
name: user.name,
admin: isAdmin,
mustChangePassword: !!user.mustChangePassword,
},
process.env.JWT_SECRET,
{ expiresIn: "1h" }
);
}
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() { async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY }); const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value; if (existing && typeof existing.value === "number") return existing.value;
@@ -175,27 +201,93 @@ router.post("/login", async (req, res) => {
const user = await User.findOne({ email }); const user = await User.findOne({ email });
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" }); 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); const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" }); if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
console.log("[debug] " + user.name + " logged in") console.log("[debug] " + user.name + " logged in")
// Check if the user role is 69 and set the admin flag const token = signUserToken(user);
const isAdmin = user.role === 69; res.status(200).json({ token, mustChangePassword: !!user.mustChangePassword });
// Create a token with the user ID, name, and admin flag if the user is an admin
const token = jwt.sign(
{ id: user._id, name: user.name, admin: isAdmin }, // Include name in the token payload
process.env.JWT_SECRET,
{ expiresIn: "1h" }
);
res.status(200).json({ token });
} catch (err) { } catch (err) {
res.status(400).json({ error: err.message }); res.status(400).json({ error: err.message });
} }
}); });
// 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 });
}
});
// 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) // Protected route to get movies (example)
router.get("/movies", verifyToken, async (req, res) => { router.get("/movies", verifyToken, async (req, res) => {
// Only authorized users will reach this point // Only authorized users will reach this point