feat: initial import — ClickerZ formation project (Express + React/Vite)

This commit is contained in:
2026-03-15 14:29:33 +01:00
commit 4e93753250
118 changed files with 71039 additions and 0 deletions

152
Backend/src/app.js Executable file
View File

@@ -0,0 +1,152 @@
// Load the express module to create a web application
const express = require("express");
const app = express();
// Configure it
/* ************************************************************************* */
// CORS Handling: Why is the current code commented out and do I need to define specific allowed origins for my project?
// CORS (Cross-Origin Resource Sharing) is a security mechanism in web browsers that blocks requests from a different domain than the server.
// You may find the following magic line in forums:
// app.use(cors());
// You should NOT do that: such code uses the `cors` module to allow all origins, which can pose security issues.
// For this pedagogical template, the CORS code is commented out to show the need for defining specific allowed origins.
// To enable CORS and define allowed origins:
// 1. Install the `cors` module in the backend directory
// 2. Uncomment the line `const cors = require("cors");`
// 3. Uncomment the section `app.use(cors({ origin: [...] }))`
// 4. Be sure to only have URLs in the array with domains from which you want to allow requests.
// For example: ["http://mysite.com", "http://another-domain.com"]
const cors = require("cors");
app.use(
cors({
origin: [
process.env.FRONTEND_URL, // keep this one, after checking the value in `backend/.env`
"http://mysite.com",
"http://another-domain.com",
],
})
);
/* ************************************************************************* */
// Request Parsing: Understanding the purpose of this part
// Request parsing is necessary to extract data sent by the client in an HTTP request.
// For example to access the body of a POST request.
// The current code contains different parsing options as comments to demonstrate different ways of extracting data.
// 1. `express.json()`: Parses requests with JSON data.
// 2. `express.urlencoded()`: Parses requests with URL-encoded data.
// 3. `express.text()`: Parses requests with raw text data.
// 4. `express.raw()`: Parses requests with raw binary data.
// Uncomment one or more of these options depending on the format of the data sent by your client:
app.use(express.json());
// app.use(express.urlencoded());
// app.use(express.text());
// app.use(express.raw());
/* ************************************************************************* */
// Cookies: Why and how to use the `cookie-parser` module?
// Cookies are small pieces of data stored in the client's browser. They are often used to store user-specific information or session data.
// The `cookie-parser` module allows us to parse and manage cookies in our Express application. It parses the `Cookie` header in incoming requests and populates `req.cookies` with an object containing the cookies.
// To use `cookie-parser`, make sure it is installed in `backend/package.json` (you may need to install it separately):
// npm install cookie-parser
// Then, require the module and use it as middleware in your Express application:
// const cookieParser = require("cookie-parser");
// app.use(cookieParser());
// Once `cookie-parser` is set up, you can read and set cookies in your routes.
// For example, to set a cookie named "username" with the value "john":
// res.cookie("username", "john");
// To read the value of a cookie named "username":
// const username = req.cookies.username;
/* ************************************************************************* */
// Import the API routes from the router module
const path = require("path");
const router = require("./router");
// Mount the API routes under the "/api" endpoint
app.use("/api", router);
/* ************************************************************************* */
// Production-ready setup: What is it for, and when should I enable it?
// The code includes commented sections to set up a production environment where the frontend and backend are served from the same server.
// What it's for:
// - Serving frontend static files from the backend, which is useful when building a single-page application with React, Angular, etc.
// - Redirecting unhandled requests (e.g., all requests not matching a defined API route) to the frontend's index.html. This allows the frontend to handle client-side routing.
// When to enable it:
// It depends on your project and its structure. If you are developing a single-page application, you'll enable these sections when you are ready to deploy your project to production.
// To enable production configuration:
// 1. Uncomment the lines related to serving static files and redirecting unhandled requests.
// 2. Ensure that the `reactBuildPath` points to the correct directory where your frontend's build artifacts are located.
// const reactBuildPath = `${__dirname}/../../frontend/dist`;
// // Serve react resources
// app.use(express.static(reactBuildPath));
// // Redirect unhandled requests to the react index file
// app.get("*", (req, res) => {
// res.sendFile(`${reactBuildPath}/index.html`);
// });
app.use("*", (req, res) => {
if (req.originalUrl.includes("assets")) {
res.sendFile(
path.resolve(__dirname, `../../frontend/dist/${req.originalUrl}`)
);
} else {
res.sendFile(path.resolve(__dirname, `../../frontend/dist/index.html`));
}
});
/* ************************************************************************* */
// Middleware for Error Logging (Uncomment to enable)
// Important: Error-handling middleware should be defined last, after other app.use() and routes calls.
/*
// Define a middleware function to log errors
const logErrors = (err, req, res, next) => {
// Log the error to the console for debugging purposes
console.error(err);
console.error("on req:", req.method, req.path);
// Pass the error to the next middleware in the stack
next(err);
};
// Mount the logErrors middleware globally
app.use(logErrors);
*/
/* ************************************************************************* */
module.exports = app;

View File

@@ -0,0 +1,421 @@
require("dotenv").config();
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const Joi = require("joi");
const nodemailer = require("nodemailer");
const tables = require("../tables");
const secretKey = process.env.APP_SECRET;
const saltRounds = 10;
const passwordSchema = Joi.string()
.min(8)
.regex(/[A-Z]/)
.message("Le mot de passe doit contenir au moins une majuscule.")
.regex(/\d/)
.message("Le mot de passe doit contenir au moins un chiffre.")
.regex(/[!@#$%^&*()_+{}[\]:;<>,.?~\\/-]/)
.message("Le mot de passe doit contenir au moins un caractère spécial.");
const nicknameSchema = Joi.string()
.alphanum()
.min(3)
.message("Le pseudo doit contenir au moins 3 caractères alphanumériques.");
const emailSchema = Joi.string().email().message("Format d'e-mail invalide.");
const validatePasswordComplexity = async (password) => {
await passwordSchema.validateAsync(password);
};
const validateEmail = async (email) => {
await emailSchema.validateAsync(email);
};
const validateUniqueNickname = async (nickname) => {
await nicknameSchema.validateAsync(nickname);
const existingUserByNickname = await tables.users.getByNickname(
nickname
);
if (existingUserByNickname) {
throw new Error("Ce pseudo est déjà pris.");
}
};
const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
const generateResetToken = (user) => {
const resetToken = jwt.sign({ user: user.id }, resetTokenSecret, {
expiresIn: "1h",
});
const base64Token = Buffer.from(resetToken).toString("base64");
return base64Token;
};
const sendPasswordResetEmail = async (user, resetToken) => {
const resetLink = `${process.env.FRONTEND_URL}/reset-password/${resetToken}`;
const mailOptions = {
from: "INSERT_YOUR_EMAIL_ADDRESS_HERE",
to: user.mail,
subject: "Réinitialisation de mot de passe",
html: `
<p>Bonjour ${user.firstname},</p>
<p>Vous avez demandé une réinitialisation de mot de passe pour votre compte.</p>
<p>Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe :</p>
<a href="${resetLink}">Réinitialiser le mot de passe</a>
<p>Si vous n'avez pas demandé cette réinitialisation, veuillez ignorer ce message.</p>
<p>Merci,</p>
`,
};
await transporter.sendMail(mailOptions);
};
const forgottenPassword = async (req, res) => {
const { mail } = req.body;
try {
const user = await tables.users.getByMail(mail);
if (!user) {
return res.status(404).json({ message: "Utilisateur non trouvé" });
}
const resetToken = generateResetToken(user);
await sendPasswordResetEmail(user, resetToken);
return res.status(200).json({
message: "Envoi d'un e-mail de réinitialisation du mot de passe",
});
} catch (error) {
console.error(
"Erreur dans l'envoi de l'e-mail de réinitialisation du mot de passe:",
error
);
return res.status(500).json({
message:
"Erreur dans l'envoi de l'e-mail de réinitialisation du mot de passe",
});
}
};
const resetPassword = async (req, res) => {
const { password } = req.body;
const resetToken = decodeURIComponent(req.params.token);
try {
const decodedToken = jwt.verify(resetToken, resetTokenSecret);
const user = await tables.users.read(decodedToken.user);
if (!user) {
return res.status(404).json({ message: "Utilisateur non trouvé" });
}
if (!password) {
return res.status(400).json({ message: "Nouveau mot de passe manquant" });
}
const { error } = Joi.string()
.min(8)
.regex(/[A-Z]/)
.message("Le mot de passe doit contenir au moins une majuscule.")
.regex(/\d/)
.message("Le mot de passe doit contenir au moins un chiffre.")
.regex(/[!@#$%^&*()_+{}[\]:;<>,.?~\\/-]/)
.message("Le mot de passe doit contenir au moins un caractère spécial.")
.validate(password);
if (error) {
return res.status(400).json({ message: error.details[0].message });
}
const hashedPassword = await bcrypt.hash(password, saltRounds);
await tables.users.edit(user.id, { password: hashedPassword });
return res
.status(200)
.json({ message: "Réinitialisation du mot de passe réussie" });
} catch (error) {
console.error("Erreur de réinitialisation du mot de passe:", error);
return res.status(500).json({
message: "Erreur de réinitialisation du mot de passe",
error,
});
}
};
const login = async (req, res) => {
const { mail, password } = req.body;
try {
const user = await tables.users.getByMail(mail);
if (!user) {
return res.status(401).json({ message: "Adresse email introuvable" });
}
const passwordMatch = await bcrypt.compare(password, user.password);
if (!passwordMatch) {
return res.status(401).json({ message: "Mot de passe incorrect" });
}
const token = jwt.sign({ user: user.id }, secretKey);
return res.status(200).json({
message: "Connexion réussie",
user: { ...user, credits: user.credits },
token,
});
} catch (error) {
console.error("Erreur lors de la connexion :", error);
return res.status(500).json({ message: "Erreur interne du serveur" });
}
};
const browse = async (req, res, next) => {
try {
const users = await tables.users.readAll();
res.json(users);
} catch (err) {
next(err);
}
};
const read = async (req, res, next) => {
try {
const { id } = req.params;
const { field } = req.query;
const user = await tables.users.read(id);
if (field && user && user[field]) {
res.json({ [field]: user[field] });
} else if (user) {
res.json(user);
} else {
res.sendStatus(404);
}
} catch (err) {
next(err);
}
};
const edit = async (req, res) => {
const userId = req.params.id;
try {
if (!req.body) {
return res.status(400).json({ message: "Corps de la requête vide" });
}
const {
nickname,
mail,
newPassword,
tetardcoin,
currentPassword,
} = req.body;
const user = await tables.users.read(userId);
if (!user) {
return res.status(404).json({ message: "Utilisateur non trouvé" });
}
const isCurrentPasswordCorrect = await bcrypt.compare(
currentPassword,
user.password
);
if (!isCurrentPasswordCorrect) {
return res.status(401).json({ message: "Mot de passe actuel incorrect" });
}
const errors = {};
if (nickname !== undefined) {
if (nickname.trim() === "") {
errors.nickname = "Le pseudo ne peut pas être vide";
} else if (nickname.trim() !== user.nickname.trim()) {
try {
await validateUniqueNickname(nickname);
} catch (error) {
errors.nickname = error.message;
}
}
}
if (mail !== undefined && mail.trim() === "") {
errors.mail = "L'e-mail ne peut pas être vide";
}
if (newPassword !== undefined && newPassword.trim() !== "") {
try {
await validatePasswordComplexity(newPassword.trim());
} catch (error) {
errors.newPassword = error.message;
}
}
if (Object.keys(errors).length > 0) {
return res.status(400).json({ errors });
}
const updatedFields = {};
if (nickname !== undefined) {
updatedFields.nickname = nickname;
}
if (mail !== undefined) {
updatedFields.mail = mail;
}
if (newPassword !== undefined && newPassword.trim() !== "") {
updatedFields.password = await bcrypt.hash(
newPassword.trim(),
saltRounds
);
}
const affectedRows = await tables.users.edit(userId, updatedFields);
if (affectedRows === 0) {
return res.status(500).json({ message: "Échec de la mise à jour" });
}
const editedUser = await tables.users.read(userId);
return res.json({ message: "Mis à jour", user: editedUser });
} catch (error) {
return res.status(500).json({
message: "Erreur lors de la mise à jour de l'utilisateur",
error: error.message,
});
}
};
const add = async (req, res, next) => {
try {
const { nickname, mail, password, confirmPassword } =
req.body;
const existingUserByMail = await tables.users.getByMail(mail);
if (existingUserByMail) {
return res
.status(400)
.json({ message: "Cette adresse e-mail est déjà enregistrée." });
}
const existingUserByNickname = await tables.users.getByNickname(
nickname
);
if (existingUserByNickname) {
return res.status(400).json({ message: "Ce pseudo est déjà pris." });
}
await validateEmail(mail);
if (password !== confirmPassword) {
return res
.status(400)
.json({ message: "Les mots de passe ne correspondent pas." });
}
try {
await passwordSchema.validateAsync(password);
} catch (validationError) {
return res.status(400).json({ message: validationError.message });
}
const hashedPassword = await bcrypt.hash(password, saltRounds);
const user = {
firstname,
lastname,
nickname,
mail,
password: hashedPassword,
tetardcoin: 1000,
};
const insertId = await tables.users.create(user);
const token = jwt.sign({ user: user.id }, secretKey);
res.status(201).json({ insertId, token });
return insertId;
} catch (err) {
console.error("Erreur lors de l'enregistrement de l'utilisateur :", err);
return next(err);
}
};
const destroy = async (req, res) => {
try {
const userId = req.params.id;
const { currentPassword } = req.body;
if (!currentPassword) {
return res.status(400).json({
errors: {
currentPassword:
"Le mot de passe actuel est requis pour la suppression du compte.",
},
});
}
const user = await tables.users.getById(userId);
const isCurrentPasswordCorrect = await bcrypt.compare(
currentPassword,
user.password
);
if (!isCurrentPasswordCorrect) {
return res.status(401).json({
errors: {
currentPassword: "Le mot de passe actuel est incorrect.",
},
});
}
await tables.users.delete(userId);
res.sendStatus(204);
} catch (err) {
console.error("Erreur lors de la suppression du compte :", err);
res.status(500).json({
errors: {
general: "Une erreur s'est produite lors de la suppression du compte.",
},
});
}
return Promise.resolve();
};
module.exports = {
browse,
read,
edit,
add,
destroy,
login,
forgottenPassword,
resetPassword,
};

View File

@@ -0,0 +1,24 @@
const jwt = require("jsonwebtoken");
const secretKey = process.env.APP_SECRET;
const verifyToken = (req, res, next) => {
const token = req.header("x-auth-token");
if (!token) {
return res
.status(401)
.json({ message: "Access denied. No token provided." });
}
try {
const decoded = jwt.verify(token, secretKey);
req.user = decoded.user;
next();
return null;
} catch (error) {
return res.status(401).json({ message: "Invalid token." });
}
};
module.exports = verifyToken;

View File

@@ -0,0 +1,16 @@
// Import database client
const database = require("../../database/client");
// Provide database access through AbstractManager class
class AbstractManager {
constructor({ table }) {
// Store the table name
this.table = table;
// Provide access to the database client
this.database = database;
}
}
// Ready to export
module.exports = AbstractManager;

101
Backend/src/models/UserManager.js Executable file
View File

@@ -0,0 +1,101 @@
const AbstractManager = require("./AbstractManager");
class UserManager extends AbstractManager {
constructor() {
super({ table: "users" });
}
// The C of CRUD - Create operation
async create(user) {
const { nickname, mail, tetardcoin, password } = user;
const [result] = await this.database.query(
`INSERT INTO ${this.table} (nickname, mail, tetardcoin, password) VALUES (?, ?, ?, ?)`,
[nickname, mail, tetardcoin, password]
);
return result.insertId;
}
// The Rs of CRUD - Read operations
async read(id, field) {
if (field) {
const [rows] = await this.database.query(
`SELECT ?? FROM ${this.table} WHERE id = ?`,
[field, id]
);
if (rows.length === 0) {
return null;
}
return rows[0][field];
}
const [rows] = await this.database.query(
`SELECT * FROM ${this.table} WHERE id = ?`,
[id]
);
if (rows.length === 0) {
return null;
}
return rows[0];
}
async getByMail(mail) {
const [rows] = await this.database.query(
`SELECT * FROM ${this.table} WHERE mail = ?`,
[mail]
);
if (rows.length === 0) {
return null;
}
return rows[0];
}
async readAll() {
const [rows] = await this.database.query(`SELECT * FROM ${this.table}`);
return rows;
}
// The U of CRUD - Update operation
async edit(id, updatedFields) {
const allowedFields = [
"mail",
"nickname",
"tetardcoin",
"password",
];
const fieldsToUpdate = Object.keys(updatedFields).filter((field) =>
allowedFields.includes(field)
);
const updateValues = fieldsToUpdate.map((field) => updatedFields[field]);
if (fieldsToUpdate.length === 0) {
return 0;
}
const updateQuery = `UPDATE ${this.table} SET ${fieldsToUpdate
.map((field) => `${field} = ?`)
.join(", ")} WHERE id = ?`;
updateValues.push(id);
const [result] = await this.database.query(updateQuery, updateValues);
return result.affectedRows;
}
// The D of CRUD - Delete operation
async delete(id) {
await this.database.query(`DELETE FROM ${this.table} WHERE id = ?`, [id]);
}
}
module.exports = UserManager;

26
Backend/src/router.js Executable file
View File

@@ -0,0 +1,26 @@
const express = require("express");
const router = express.Router();
/* ************************************************************************* */
// Define Your API Routes Here
/* ************************************************************************* */
// Import Controllers
const userControllers = require("./controllers/userControllers");
const verifyToken = require("./middlewares/verifyToken");
// User management
router.get("/users", verifyToken, userControllers.browse);
router.get("/users/:id", userControllers.read);
router.get("/users/:id/field", userControllers.read);
router.put("/users/:id", userControllers.edit);
router.post("/users", userControllers.add);
router.delete("/users/:id", userControllers.destroy);
router.post("/login", userControllers.login);
/* ************************************************************************* */
module.exports = router;

0
Backend/src/services/.gitkeep Executable file
View File

38
Backend/src/tables.js Executable file
View File

@@ -0,0 +1,38 @@
/* ************************************************************************* */
// Register Data Managers for Tables
/* ************************************************************************* */
// Import the manager modules responsible for handling data operations on the tables
const UserManager = require("./models/UserManager");
const managers = [
UserManager,
// Add other managers here
];
// Create an empty object to hold data managers for different tables
const tables = {};
// Register each manager as data access point for its table
managers.forEach((ManagerClass) => {
const manager = new ManagerClass();
tables[manager.table] = manager;
});
/* ************************************************************************* */
// Use a Proxy to customize error messages when trying to access a non-existing table
// Export the Proxy instance with custom error handling
module.exports = new Proxy(tables, {
get(obj, prop) {
// Check if the property (table) exists in the tables object
if (prop in obj) return obj[prop];
// If the property (table) does not exist, throw a ReferenceError with a custom error message
throw new ReferenceError(
`tables.${prop} is not defined. Did you register it in ${__filename}?`
);
},
});