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

@@ -10,7 +10,8 @@
<!-- Auth Modal ──────────────────────────────────────────────────────────── -->
<div class="authOverlay" id="authOverlay">
<div class="authModal">
<div class="authTabs">
<!-- Normal tabs (login / register) -->
<div class="authTabs" id="authTabsBar">
<button type="button" class="authTab authTab--active" id="tabLogin">Se connecter</button>
<button type="button" class="authTab" id="tabRegister">S'enregistrer</button>
</div>
@@ -27,6 +28,7 @@
</div>
<div class="authError hidden" id="loginError"></div>
<button type="submit" class="authSubmit">Se connecter</button>
<button type="button" class="authLink" id="forgotPasswordBtn">Mot de passe oublié ?</button>
</form>
<!-- Register form -->
@@ -59,6 +61,51 @@
<div class="authError hidden" id="registerError"></div>
<button type="submit" class="authSubmit">Créer le compte</button>
</form>
<!-- Forgot password form -->
<div class="authPanel hidden" id="forgotPanel">
<p class="authPanelTitle">Mot de passe oublié</p>
<p class="authPanelDesc">Entrez votre adresse courriel pour recevoir un lien de réinitialisation.</p>
<form id="forgotForm">
<div class="authField">
<label>Adresse courriel</label>
<input type="email" id="forgotEmail" autocomplete="email" required />
</div>
<div class="authError hidden" id="forgotError"></div>
<button type="submit" class="authSubmit">Envoyer le lien</button>
</form>
<button type="button" class="authLink" id="backToLoginBtn">← Retour à la connexion</button>
</div>
<!-- Check your email message (after register or forgot password) -->
<div class="authPanel hidden" id="checkEmailPanel">
<div class="authSuccessIcon"></div>
<p class="authPanelTitle" id="checkEmailTitle">Vérifiez votre courriel</p>
<p class="authPanelDesc" id="checkEmailMsg">Un email de confirmation a été envoyé. Cliquez sur le lien dans l'email pour activer votre compte.</p>
<button type="button" class="authSubmit authSubmit--ghost" id="resendEmailBtn">Renvoyer l'email</button>
<button type="button" class="authLink" id="backToLoginBtn2">← Retour à la connexion</button>
</div>
<!-- Reset password form (shown when ?reset=TOKEN is in URL) -->
<div class="authPanel hidden" id="resetPanel">
<p class="authPanelTitle">Nouveau mot de passe</p>
<form id="resetForm">
<div class="authField">
<label>Nouveau mot de passe <span class="authHint">(6 caractères min.)</span></label>
<input type="password" id="resetPassword" autocomplete="new-password" required />
</div>
<div class="authError hidden" id="resetError"></div>
<button type="submit" class="authSubmit">Réinitialiser</button>
</form>
</div>
<!-- Email confirmed success (shown after redirect) -->
<div class="authPanel hidden" id="confirmedPanel">
<div class="authSuccessIcon"></div>
<p class="authPanelTitle">Email confirmé !</p>
<p class="authPanelDesc">Votre adresse email a été confirmée. Vous pouvez maintenant vous connecter.</p>
<button type="button" class="authSubmit" id="goToLoginBtn">Se connecter</button>
</div>
</div>
</div>

View File

@@ -50,6 +50,30 @@ export async function apiRegister(username, email, password, team) {
});
}
export async function apiResendConfirmation(email) {
return fetch("/api/auth/resend-confirmation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
}
export async function apiForgotPassword(email) {
return fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
}
export async function apiResetPassword(token, password) {
return fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
}
export async function apiGetMe(token) {
return fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },

View File

@@ -1,13 +1,18 @@
import { apiLogin, apiRegister, apiGetMe } from "./api.js";
import { apiLogin, apiRegister, apiGetMe, apiResendConfirmation, apiForgotPassword, apiResetPassword } from "./api.js";
import { setCurrentTeam, refreshFromServer } from "./game.js";
// ── DOM refs ──────────────────────────────────────────────────────────────────
const authOverlay = document.getElementById("authOverlay");
const authTabsBar = document.getElementById("authTabsBar");
const tabLogin = document.getElementById("tabLogin");
const tabRegister = document.getElementById("tabRegister");
const loginForm = document.getElementById("loginForm");
const registerForm = document.getElementById("registerForm");
const forgotPanel = document.getElementById("forgotPanel");
const checkEmailPanel = document.getElementById("checkEmailPanel");
const resetPanel = document.getElementById("resetPanel");
const confirmedPanel = document.getElementById("confirmedPanel");
const loginUsernameEl = document.getElementById("loginUsername");
const loginPasswordEl = document.getElementById("loginPassword");
const loginErrorEl = document.getElementById("loginError");
@@ -15,6 +20,14 @@ const regUsernameEl = document.getElementById("regUsername");
const regEmailEl = document.getElementById("regEmail");
const regPasswordEl = document.getElementById("regPassword");
const registerErrorEl = document.getElementById("registerError");
const forgotForm = document.getElementById("forgotForm");
const forgotEmailEl = document.getElementById("forgotEmail");
const forgotErrorEl = document.getElementById("forgotError");
const resetForm = document.getElementById("resetForm");
const resetPasswordEl = document.getElementById("resetPassword");
const resetErrorEl = document.getElementById("resetError");
const checkEmailMsg = document.getElementById("checkEmailMsg");
const resendEmailBtn = document.getElementById("resendEmailBtn");
const userDisplayEl = document.getElementById("userDisplay");
const logoutBtn = document.getElementById("logoutBtn");
@@ -23,11 +36,39 @@ const logoutBtn = document.getElementById("logoutBtn");
export let authToken = localStorage.getItem("authToken") ?? null;
export let currentUser = null;
// Tracks which email to use for resend (set after register)
let pendingEmail = null;
// ── Helpers ───────────────────────────────────────────────────────────────────
function showError(el, msg) { el.textContent = msg; el.classList.remove("hidden"); }
function clearError(el) { el.textContent = ""; el.classList.add("hidden"); }
// Show only one panel, hide all others
const allPanels = [loginForm, registerForm, forgotPanel, checkEmailPanel, resetPanel, confirmedPanel];
function showPanel(panel) {
for (const p of allPanels) p.classList.add("hidden");
authTabsBar.classList.add("hidden");
panel.classList.remove("hidden");
}
function showLoginTab() {
for (const p of allPanels) p.classList.add("hidden");
authTabsBar.classList.remove("hidden");
loginForm.classList.remove("hidden");
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
}
function showRegisterTab() {
for (const p of allPanels) p.classList.add("hidden");
authTabsBar.classList.remove("hidden");
registerForm.classList.remove("hidden");
tabRegister.classList.add("authTab--active");
tabLogin.classList.remove("authTab--active");
}
export function showAuthOverlay() { authOverlay.classList.remove("hidden"); }
export function hideAuthOverlay() { authOverlay.classList.add("hidden"); }
@@ -49,6 +90,7 @@ function logout() {
userDisplayEl.textContent = "—";
logoutBtn.classList.add("hidden");
showAuthOverlay();
showLoginTab();
}
// ── Session restore ───────────────────────────────────────────────────────────
@@ -66,21 +108,63 @@ export async function tryRestoreSession() {
}
}
// ── Handle URL params (email confirmation / password reset) ───────────────────
export function handleUrlEmailFlows() {
const params = new URLSearchParams(window.location.search);
if (params.has("email_confirm")) {
// Trigger confirmation via API redirect — this is handled server-side.
// The server redirects to /?email_confirmed=1 or /?confirm_error=...
const token = params.get("email_confirm");
window.history.replaceState({}, "", "/");
// Call server-side confirm endpoint by navigating (GET request)
window.location.href = `/api/auth/confirm-email?token=${encodeURIComponent(token)}`;
return true;
}
if (params.has("email_confirmed")) {
window.history.replaceState({}, "", "/");
showAuthOverlay();
showPanel(confirmedPanel);
return true;
}
if (params.has("confirm_error")) {
const err = params.get("confirm_error");
window.history.replaceState({}, "", "/");
showAuthOverlay();
showLoginTab();
const msgs = {
expired: "Le lien de confirmation a expiré. Connectez-vous pour en recevoir un nouveau.",
invalid: "Lien de confirmation invalide.",
server_error: "Erreur serveur. Réessayez plus tard.",
};
showError(loginErrorEl, msgs[err] ?? "Erreur de confirmation.");
return true;
}
if (params.has("reset")) {
const token = params.get("reset");
window.history.replaceState({}, "", "/");
showAuthOverlay();
resetPanel.dataset.token = token;
showPanel(resetPanel);
return true;
}
return false;
}
// ── Tab switching ─────────────────────────────────────────────────────────────
tabLogin.addEventListener("click", () => {
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
loginForm.classList.remove("hidden");
registerForm.classList.add("hidden");
showLoginTab();
clearError(loginErrorEl);
});
tabRegister.addEventListener("click", () => {
tabRegister.classList.add("authTab--active");
tabLogin.classList.remove("authTab--active");
registerForm.classList.remove("hidden");
loginForm.classList.add("hidden");
showRegisterTab();
clearError(registerErrorEl);
});
@@ -96,15 +180,23 @@ loginForm.addEventListener("submit", async (e) => {
const res = await apiLogin(username, password);
const data = await res.json();
if (!res.ok) {
const msgs = { invalid_credentials: "Invalid username or password.", missing_fields: "Please fill in all fields." };
showError(loginErrorEl, msgs[data.error] ?? "Login failed.");
if (data.error === "email_not_verified") {
// Show check-email panel with resend option
pendingEmail = null; // email unknown here, ask user to re-register or resend
checkEmailMsg.textContent = "Votre adresse email n'a pas encore été confirmée. Vérifiez votre boîte de réception ou renvoyez l'email de confirmation.";
resendEmailBtn.dataset.email = "";
showPanel(checkEmailPanel);
return;
}
const msgs = { invalid_credentials: "Identifiant ou mot de passe invalide.", missing_fields: "Veuillez remplir tous les champs." };
showError(loginErrorEl, msgs[data.error] ?? "Connexion échouée.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
showError(loginErrorEl, "Network error. Try again.");
showError(loginErrorEl, "Erreur réseau. Réessayez.");
}
});
@@ -117,30 +209,134 @@ registerForm.addEventListener("submit", async (e) => {
const email = regEmailEl.value.trim();
const password = regPasswordEl.value;
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
if (!teamInput) { showError(registerErrorEl, "Please choose a team."); return; }
if (!teamInput) { showError(registerErrorEl, "Veuillez choisir une équipe."); return; }
try {
const res = await apiRegister(username, email, password, teamInput.value);
const data = await res.json();
if (!res.ok) {
const msgs = {
username_taken: "This username is already taken.",
email_taken: "This email is already registered.",
password_too_short:"Password must be at least 6 characters.",
invalid_username: "Username must be 232 characters.",
missing_fields: "Please fill in all fields.",
invalid_team: "Invalid team selected.",
username_taken: "Ce nom d'utilisateur est déjà pris.",
email_taken: "Cette adresse email est déjà utilisée.",
password_too_short:"Le mot de passe doit comporter au moins 6 caractères.",
invalid_username: "Le nom d'utilisateur doit comporter entre 2 et 32 caractères.",
missing_fields: "Veuillez remplir tous les champs.",
invalid_team: "Équipe invalide.",
};
showError(registerErrorEl, msgs[data.error] ?? "Registration failed.");
showError(registerErrorEl, msgs[data.error] ?? "Inscription échouée.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
// Registration successful — pending email confirmation
pendingEmail = email;
resendEmailBtn.dataset.email = email;
checkEmailMsg.textContent = `Un email de confirmation a été envoyé à ${email}. Cliquez sur le lien pour activer votre compte.`;
document.getElementById("checkEmailTitle").textContent = "Vérifiez votre courriel";
showPanel(checkEmailPanel);
} catch {
showError(registerErrorEl, "Network error. Try again.");
showError(registerErrorEl, "Erreur réseau. Réessayez.");
}
});
// ── Forgot password ───────────────────────────────────────────────────────────
document.getElementById("forgotPasswordBtn").addEventListener("click", () => {
clearError(forgotErrorEl);
forgotEmailEl.value = "";
showPanel(forgotPanel);
});
document.getElementById("backToLoginBtn").addEventListener("click", () => {
showLoginTab();
clearError(loginErrorEl);
});
forgotForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearError(forgotErrorEl);
const email = forgotEmailEl.value.trim();
if (!email) return;
try {
await apiForgotPassword(email);
// Always show the same message regardless of whether email exists (anti-enumeration)
checkEmailMsg.textContent = `Si un compte existe pour ${email}, un lien de réinitialisation a été envoyé.`;
document.getElementById("checkEmailTitle").textContent = "Email envoyé";
resendEmailBtn.dataset.email = "";
resendEmailBtn.classList.add("hidden");
showPanel(checkEmailPanel);
} catch {
showError(forgotErrorEl, "Erreur réseau. Réessayez.");
}
});
// ── Check-email panel ─────────────────────────────────────────────────────────
document.getElementById("backToLoginBtn2").addEventListener("click", () => {
resendEmailBtn.classList.remove("hidden");
showLoginTab();
clearError(loginErrorEl);
});
resendEmailBtn.addEventListener("click", async () => {
const email = resendEmailBtn.dataset.email;
if (!email) return;
resendEmailBtn.disabled = true;
resendEmailBtn.textContent = "Envoi…";
try {
await apiResendConfirmation(email);
resendEmailBtn.textContent = "Email renvoyé !";
} catch {
resendEmailBtn.textContent = "Erreur — réessayez";
} finally {
setTimeout(() => {
resendEmailBtn.disabled = false;
resendEmailBtn.textContent = "Renvoyer l'email";
}, 3000);
}
});
// ── Reset password form ───────────────────────────────────────────────────────
resetForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearError(resetErrorEl);
const token = resetPanel.dataset.token;
const password = resetPasswordEl.value;
if (!token) { showError(resetErrorEl, "Token manquant."); return; }
try {
const res = await apiResetPassword(token, password);
const data = await res.json();
if (!res.ok) {
const msgs = {
invalid_token: "Ce lien est invalide.",
token_expired: "Ce lien a expiré. Refaites une demande.",
password_too_short: "Le mot de passe doit comporter au moins 6 caractères.",
missing_fields: "Veuillez remplir tous les champs.",
};
showError(resetErrorEl, msgs[data.error] ?? "Erreur lors de la réinitialisation.");
return;
}
showLoginTab();
showError(loginErrorEl, ""); // clear
clearError(loginErrorEl);
// Show success in login form briefly
loginErrorEl.style.color = "rgba(100,220,100,0.95)";
loginErrorEl.style.background = "rgba(30,100,30,0.12)";
loginErrorEl.style.border = "1px solid rgba(30,100,30,0.25)";
showError(loginErrorEl, "Mot de passe réinitialisé ! Vous pouvez vous connecter.");
setTimeout(() => {
loginErrorEl.removeAttribute("style");
clearError(loginErrorEl);
}, 6000);
} catch {
showError(resetErrorEl, "Erreur réseau. Réessayez.");
}
});
// ── Confirmed panel ───────────────────────────────────────────────────────────
document.getElementById("goToLoginBtn").addEventListener("click", () => {
showLoginTab();
});
// ── Logout ────────────────────────────────────────────────────────────────────
logoutBtn.addEventListener("click", logout);

View File

@@ -20,6 +20,7 @@ import {
tryRestoreSession,
showAuthOverlay,
hideAuthOverlay,
handleUrlEmailFlows,
} from "./auth.js";
// ── DOM refs ──────────────────────────────────────────────────────────────────
@@ -90,11 +91,15 @@ async function boot() {
// Load the SVG playfield mask before any drawing or data fetch
await loadPlayfieldMask();
const restored = await tryRestoreSession();
if (!restored) {
showAuthOverlay();
} else {
hideAuthOverlay();
// Handle email confirmation / password reset URL params first
const urlHandled = handleUrlEmailFlows();
if (!urlHandled) {
const restored = await tryRestoreSession();
if (!restored) {
showAuthOverlay();
} else {
hideAuthOverlay();
}
}
try {

View File

@@ -192,6 +192,76 @@ body {
background: rgba(113, 199, 255, 0.28);
}
.authSubmit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.authSubmit--ghost {
background: transparent;
border-color: rgba(255, 255, 255, 0.15);
}
.authSubmit--ghost:hover {
background: rgba(255, 255, 255, 0.06);
}
/* Link-style button inside auth modal */
.authLink {
margin-top: 2px;
background: none;
border: none;
color: rgba(113, 199, 255, 0.75);
font-size: 13px;
cursor: pointer;
text-align: center;
padding: 4px 0;
transition: color 0.15s;
}
.authLink:hover {
color: rgba(113, 199, 255, 1);
}
/* Free-standing panel (not a form) inside the auth modal */
.authPanel {
padding: 28px 22px 24px;
display: flex;
flex-direction: column;
gap: 14px;
text-align: center;
}
.authPanel.hidden {
display: none;
}
.authPanel form {
display: flex;
flex-direction: column;
gap: 14px;
text-align: left;
}
.authPanelTitle {
font-size: 17px;
font-weight: 700;
color: #e9eef6;
margin: 0;
}
.authPanelDesc {
font-size: 13px;
color: rgba(233, 238, 246, 0.65);
margin: 0;
line-height: 1.5;
}
.authSuccessIcon {
font-size: 40px;
line-height: 1;
opacity: 0.85;
}
/* ── Score board ──────────────────────────────────────────────────────────── */