Private
Public Access
1
0

feat(email): Adding email options for registration

This commit is contained in:
gauvainboiche
2026-03-31 11:35:12 +02:00
parent 655928318e
commit c7ad5898e6
61 changed files with 16104 additions and 47 deletions

View File

@@ -11,6 +11,23 @@ export async function initUsersSchema() {
password_hash TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
email_verified BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
// Add email_verified to existing deployments that lack it
await usersPool.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false;
`);
await usersPool.query(`
CREATE TABLE IF NOT EXISTS email_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
type TEXT NOT NULL CHECK (type IN ('confirm', 'reset')),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
@@ -22,7 +39,7 @@ export async function createUser(username, email, passwordHash, team) {
const { rows } = await usersPool.query(
`INSERT INTO users (username, email, password_hash, team)
VALUES ($1, $2, $3, $4)
RETURNING id, username, email, team, role`,
RETURNING id, username, email, team, role, email_verified`,
[username, email, passwordHash, team]
);
return rows[0];
@@ -30,16 +47,64 @@ export async function createUser(username, email, passwordHash, team) {
export async function getUserByUsername(username) {
const { rows } = await usersPool.query(
`SELECT id, username, email, team, role, password_hash FROM users WHERE username = $1`,
`SELECT id, username, email, team, role, password_hash, email_verified FROM users WHERE username = $1`,
[username]
);
return rows[0] ?? null;
}
export async function getUserByEmail(email) {
const { rows } = await usersPool.query(
`SELECT id, username, email, team, role, email_verified FROM users WHERE email = $1`,
[email]
);
return rows[0] ?? null;
}
export async function getUserById(id) {
const { rows } = await usersPool.query(
`SELECT id, username, email, team, role FROM users WHERE id = $1`,
`SELECT id, username, email, team, role, email_verified FROM users WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
}
export async function createEmailToken(userId, token, type, expiresAt) {
// Delete any existing token of the same type for this user
await usersPool.query(
`DELETE FROM email_tokens WHERE user_id = $1 AND type = $2`,
[userId, type]
);
await usersPool.query(
`INSERT INTO email_tokens (user_id, token, type, expires_at) VALUES ($1, $2, $3, $4)`,
[userId, token, type, expiresAt]
);
}
export async function getEmailToken(token, type) {
const { rows } = await usersPool.query(
`SELECT et.*, u.email FROM email_tokens et
JOIN users u ON u.id = et.user_id
WHERE et.token = $1 AND et.type = $2`,
[token, type]
);
return rows[0] ?? null;
}
export async function deleteEmailToken(token) {
await usersPool.query(`DELETE FROM email_tokens WHERE token = $1`, [token]);
}
export async function markEmailVerified(userId) {
await usersPool.query(
`UPDATE users SET email_verified = true WHERE id = $1`,
[userId]
);
}
export async function updatePassword(userId, passwordHash) {
await usersPool.query(
`UPDATE users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId]
);
}

45
server/email.js Normal file
View File

@@ -0,0 +1,45 @@
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST ?? "mailpit",
port: Number(process.env.SMTP_PORT ?? 1025),
secure: process.env.SMTP_SECURE === "true",
...(process.env.SMTP_USER
? { auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } }
: {}),
});
const FROM = process.env.SMTP_FROM ?? "Star Wars Wild Space <noreply@wildspace.local>";
const APP_URL = (process.env.APP_URL ?? "http://localhost:8080").replace(/\/$/, "");
export async function sendConfirmationEmail(to, token) {
const url = `${APP_URL}/?email_confirm=${encodeURIComponent(token)}`;
await transport.sendMail({
from: FROM,
to,
subject: "Confirmez votre adresse email — Star Wars Wild Space",
text: `Cliquez sur le lien ci-dessous pour confirmer votre compte :\n\n${url}\n\nCe lien expire dans 24 heures.`,
html: `
<h2>Star Wars — Wild Space</h2>
<p>Cliquez sur le lien ci-dessous pour confirmer votre adresse email :</p>
<p><a href="${url}" style="font-size:16px">${url}</a></p>
<p><small>Ce lien expire dans 24 heures.</small></p>
`,
});
}
export async function sendPasswordResetEmail(to, token) {
const url = `${APP_URL}/?reset=${encodeURIComponent(token)}`;
await transport.sendMail({
from: FROM,
to,
subject: "Réinitialisation de mot de passe — Star Wars Wild Space",
text: `Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe :\n\n${url}\n\nCe lien expire dans 1 heure. Si vous n'avez pas fait cette demande, ignorez cet email.`,
html: `
<h2>Star Wars — Wild Space</h2>
<p>Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe :</p>
<p><a href="${url}" style="font-size:16px">${url}</a></p>
<p><small>Ce lien expire dans 1 heure. Si vous n'avez pas fait cette demande, vous pouvez ignorer cet email.</small></p>
`,
});
}

View File

@@ -1,8 +1,20 @@
import express from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { JWT_SECRET, authMiddleware } from "../middleware/auth.js";
import { createUser, getUserByUsername, getUserById } from "../db/usersDb.js";
import {
createUser,
getUserByUsername,
getUserByEmail,
getUserById,
createEmailToken,
getEmailToken,
deleteEmailToken,
markEmailVerified,
updatePassword,
} from "../db/usersDb.js";
import { sendConfirmationEmail, sendPasswordResetEmail } from "../email.js";
const router = express.Router();
@@ -14,6 +26,10 @@ function issueToken(user) {
);
}
function generateToken() {
return crypto.randomBytes(32).toString("hex");
}
// POST /api/auth/register
router.post("/register", async (req, res) => {
const { username, email, password, team } = req.body ?? {};
@@ -32,11 +48,16 @@ router.post("/register", async (req, res) => {
try {
const passwordHash = await bcrypt.hash(password, 12);
const user = await createUser(username.trim(), email.trim().toLowerCase(), passwordHash, team);
const token = issueToken(user);
return res.status(201).json({
token,
user: { id: user.id, username: user.username, team: user.team, role: user.role },
});
// Create and send confirmation token
const token = generateToken();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await createEmailToken(user.id, token, "confirm", expiresAt);
sendConfirmationEmail(user.email, token).catch((e) =>
console.error("[email] Failed to send confirmation email:", e.message)
);
return res.status(201).json({ pending_verification: true });
} catch (e) {
if (e.code === "23505") {
if (e.constraint?.includes("email")) return res.status(409).json({ error: "email_taken" });
@@ -56,6 +77,7 @@ router.post("/login", async (req, res) => {
if (!user) return res.status(401).json({ error: "invalid_credentials" });
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: "invalid_credentials" });
if (!user.email_verified) return res.status(403).json({ error: "email_not_verified" });
const token = issueToken(user);
return res.json({
token,
@@ -80,4 +102,92 @@ router.get("/me", authMiddleware, async (req, res) => {
}
});
export default router;
// POST /api/auth/resend-confirmation
router.post("/resend-confirmation", async (req, res) => {
const { email } = req.body ?? {};
if (!email) return res.status(400).json({ error: "missing_fields" });
try {
const user = await getUserByEmail(email.trim().toLowerCase());
// Always respond OK to avoid user enumeration
if (!user || user.email_verified) return res.json({ ok: true });
const token = generateToken();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await createEmailToken(user.id, token, "confirm", expiresAt);
sendConfirmationEmail(user.email, token).catch((e) =>
console.error("[email] Failed to resend confirmation email:", e.message)
);
return res.json({ ok: true });
} catch (e) {
console.error(e);
return res.status(500).json({ error: "database_error" });
}
});
// GET /api/auth/confirm-email?token=TOKEN
router.get("/confirm-email", async (req, res) => {
const { token } = req.query;
if (!token) return res.redirect("/?confirm_error=invalid");
try {
const row = await getEmailToken(token, "confirm");
if (!row) return res.redirect("/?confirm_error=invalid");
if (new Date(row.expires_at) < new Date()) {
await deleteEmailToken(token);
return res.redirect("/?confirm_error=expired");
}
await markEmailVerified(row.user_id);
await deleteEmailToken(token);
return res.redirect("/?email_confirmed=1");
} catch (e) {
console.error(e);
return res.redirect("/?confirm_error=server_error");
}
});
// POST /api/auth/forgot-password
router.post("/forgot-password", async (req, res) => {
const { email } = req.body ?? {};
if (!email) return res.status(400).json({ error: "missing_fields" });
try {
const user = await getUserByEmail(email.trim().toLowerCase());
// Always respond OK to avoid user enumeration
if (!user) return res.json({ ok: true });
const token = generateToken();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await createEmailToken(user.id, token, "reset", expiresAt);
sendPasswordResetEmail(user.email, token).catch((e) =>
console.error("[email] Failed to send reset email:", e.message)
);
return res.json({ ok: true });
} catch (e) {
console.error(e);
return res.status(500).json({ error: "database_error" });
}
});
// POST /api/auth/reset-password
router.post("/reset-password", async (req, res) => {
const { token, password } = req.body ?? {};
if (!token || !password) return res.status(400).json({ error: "missing_fields" });
if (typeof password !== "string" || password.length < 6) {
return res.status(400).json({ error: "password_too_short" });
}
try {
const row = await getEmailToken(token, "reset");
if (!row) return res.status(400).json({ error: "invalid_token" });
if (new Date(row.expires_at) < new Date()) {
await deleteEmailToken(token);
return res.status(400).json({ error: "token_expired" });
}
const passwordHash = await bcrypt.hash(password, 12);
await updatePassword(row.user_id, passwordHash);
await deleteEmailToken(token);
return res.json({ ok: true });
} catch (e) {
console.error(e);
return res.status(500).json({ error: "database_error" });
}
});
export default router;