feat: Implement password reset and change functionality
Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
co-authored by
mathieumoran1
parent
7a5ef7e9a0
commit
c83b4685a2
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -70,6 +70,30 @@
|
||||
</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>
|
||||
<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>.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+25
-4
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+103
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user