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 @@ + + +
+ + +
+ + Génère un mot de passe temporaire aléatoire. L'utilisateur devra choisir un nouveau mot de passe à sa prochaine connexion. +
+ + + + +
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