From c83b4685a264a450a64fd9aaecc8d14e3913587e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Dec 2025 23:13:51 +0000 Subject: [PATCH] feat: Implement password reset and change functionality Co-authored-by: mathieumoran1 --- models/User.js | 4 ++ public/change-password.html | 45 ++++++++++++ public/control.html | 24 +++++++ public/js/app_change-password.js | 103 ++++++++++++++++++++++++++++ public/js/app_checklist.js | 4 ++ public/js/app_control.js | 51 ++++++++++++++ public/js/app_index.js | 29 ++++++-- public/js/app_movies.js | 4 ++ public/js/app_register.js | 22 +++++- public/js/app_user-stat.js | 4 ++ routes/users.js | 114 ++++++++++++++++++++++++++++--- 11 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 public/change-password.html create mode 100644 public/js/app_change-password.js diff --git a/models/User.js b/models/User.js index 6ad82bf..14751e3 100644 --- a/models/User.js +++ b/models/User.js @@ -5,6 +5,10 @@ const userSchema = new mongoose.Schema({ email: { type: String, required: true, unique: true }, password: { type: String, required: true }, 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: [{ imdb_id: { type: String, required: false }, watchedDate: { type: Date, default: Date.now } // Store the watched date diff --git a/public/change-password.html b/public/change-password.html new file mode 100644 index 0000000..f497085 --- /dev/null +++ b/public/change-password.html @@ -0,0 +1,45 @@ + + + + + + Pool Oscars - Changer le mot de passe + + + + + +
+
+
+ +
+ + + + + +
+
+ + +
Minimum 8 caractères.
+
+
+ + +
+ +
+ + +
+
+ + + + + + diff --git a/public/control.html b/public/control.html index 61ad6f0..68a0613 100644 --- a/public/control.html +++ b/public/control.html @@ -70,6 +70,30 @@ +

Admin - Réinitialiser mot de passe utilisateur

+

+ Génère un mot de passe temporaire aléatoire. L'utilisateur devra choisir un nouveau mot de passe à sa prochaine connexion. +

+ + + +
+
+
+ + +
+
+ +
+
+
+ Important: le mot de passe temporaire est affiché une seule fois ici. Copie-le et envoie-le à l'utilisateur. +
+
+
+
+

Admin - Ajouter un film

Ajoute un film via l’API (/api/movies/add). Le serveur récupère automatiquement le titre/plot/affiche depuis OMDb avec l’imdb_id. diff --git a/public/js/app_change-password.js b/public/js/app_change-password.js new file mode 100644 index 0000000..8da74f8 --- /dev/null +++ b/public/js/app_change-password.js @@ -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'); + } + }); +}; + diff --git a/public/js/app_checklist.js b/public/js/app_checklist.js index 0712e3c..01ef2fb 100644 --- a/public/js/app_checklist.js +++ b/public/js/app_checklist.js @@ -27,6 +27,10 @@ window.onload = async function () { if (token) { try { const decoded = JSON.parse(atob(token.split('.')[1])); + if (decoded.mustChangePassword) { + window.location.href = '/change-password.html'; + return; + } const userName = decoded.name; document.getElementById('user-name').textContent = userName; if (decoded.admin) { diff --git a/public/js/app_control.js b/public/js/app_control.js index e704066..d264c5c 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -21,6 +21,11 @@ window.onload = async function () { return; } + if (decoded.mustChangePassword) { + window.location.href = '/change-password.html'; + return; + } + document.getElementById('user-name').textContent = decoded.name || ''; // Admin-only page: if token doesn't contain admin flag, bounce. @@ -68,6 +73,17 @@ window.onload = async function () { 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) { const n = Number(value); if (!Number.isInteger(n)) return null; @@ -123,6 +139,41 @@ window.onload = async function () { 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) { e.preventDefault(); diff --git a/public/js/app_index.js b/public/js/app_index.js index 9d4649e..b625f9e 100644 --- a/public/js/app_index.js +++ b/public/js/app_index.js @@ -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 -if (localStorage.getItem('auth_token')) { - // If the token is found, redirect to /movies.html - window.location.href = '/movies.html'; +const existingToken = localStorage.getItem('auth_token'); +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'; + } + } catch (_) { + localStorage.removeItem('auth_token'); } +} // Handle login form submission document.getElementById('login-form').addEventListener('submit', async function (e) { @@ -21,7 +37,12 @@ if (localStorage.getItem('auth_token')) { const data = await res.json(); // Save the token to localStorage localStorage.setItem('auth_token', data.token); - window.location.href = '/movies.html'; // Redirect to movies page on success + + if (data.mustChangePassword) { + window.location.href = '/change-password.html'; + } else { + window.location.href = '/movies.html'; // Redirect to movies page on success + } } else { const error = await res.json(); alert(`${error.message}`); diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 0b0f13a..8dfa399 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -50,6 +50,10 @@ window.onload = async function () { if (token) { try { const decoded = JSON.parse(atob(token.split('.')[1])); + if (decoded.mustChangePassword) { + window.location.href = '/change-password.html'; + return; + } const userName = decoded.name; document.getElementById('user-name').textContent = userName; if (decoded.admin) { diff --git a/public/js/app_register.js b/public/js/app_register.js index 0e38a8f..cd83fcc 100644 --- a/public/js/app_register.js +++ b/public/js/app_register.js @@ -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 -if (localStorage.getItem('auth_token')) { - // If the token is found, redirect to /movies.html - window.location.href = '/movies.html'; +const existingToken = localStorage.getItem('auth_token'); +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'; + } + } catch (_) { + localStorage.removeItem('auth_token'); } +} // Handle registration form submission document.getElementById('register-form').addEventListener('submit', async function (e) { diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js index 87900c7..95cbe5e 100644 --- a/public/js/app_user-stat.js +++ b/public/js/app_user-stat.js @@ -16,6 +16,10 @@ window.onload = async function () { if (token) { try { 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; document.getElementById('user-name').textContent = userName; if (decoded.admin) { diff --git a/routes/users.js b/routes/users.js index e62c33f..437a558 100644 --- a/routes/users.js +++ b/routes/users.js @@ -1,6 +1,7 @@ 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"); @@ -60,6 +61,31 @@ function parseOscarYear(raw) { 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() { const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY }); 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 }); 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") - // Check if the user role is 69 and set the admin flag - const isAdmin = user.role === 69; - - // 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 }); + const token = signUserToken(user); + res.status(200).json({ token, mustChangePassword: !!user.mustChangePassword }); } catch (err) { 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) router.get("/movies", verifyToken, async (req, res) => { // Only authorized users will reach this point -- 2.54.0