feat(email): Adding email options for registration
This commit is contained in:
@@ -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
45
server/email.js
Normal 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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user