feat: add email verification & password reset link

This commit is contained in:
Henri Bourcereau 2026-05-03 21:31:36 +02:00
parent 440bf12c43
commit 614be65c90
20 changed files with 929 additions and 62 deletions

View file

@ -292,6 +292,26 @@ a:hover { text-decoration: underline; }
.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; }
.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; }
.portal-link {
color: var(--ui-gold);
text-decoration: none;
font-size: 0.875rem;
}
.portal-link:hover { text-decoration: underline; }
.portal-verification-banner {
background: rgba(200,164,72,0.08);
border: 1px solid rgba(200,164,72,0.35);
border-radius: 6px;
padding: 1.25rem;
text-align: center;
}
.portal-verification-banner p {
margin-bottom: 0.75rem;
color: var(--ui-parchment);
font-size: 0.9rem;
}
/* ── Share URL row (lobby waiting card + game top bar) ──────────── */
.share-url-row {
display: flex;

View file

@ -65,8 +65,28 @@
"create_account": "Create account",
"account_title": "Account",
"label_username": "Username",
"label_username_or_email": "Username or email",
"label_password": "Password",
"label_confirm_password": "Confirm password",
"passwords_do_not_match": "Passwords do not match.",
"label_email": "Email",
"forgot_password_link": "Forgot password?",
"forgot_password_title": "Reset password",
"forgot_password_email_label": "Email address",
"forgot_password_submit": "Send reset link",
"forgot_password_sent": "If an account with this email exists, a reset link has been sent to that address.",
"reset_password_title": "New password",
"new_password_label": "New password",
"reset_password_submit": "Reset password",
"reset_password_success": "Password reset successfully. You can now sign in.",
"reset_password_invalid": "This reset link is invalid or has expired.",
"verify_email_title": "Email verification",
"verify_email_checking": "Verifying your email…",
"verify_email_success": "Your email has been verified.",
"verify_email_invalid": "This verification link is invalid or has expired.",
"email_not_verified_banner": "Please verify your email address — check your inbox.",
"resend_verification": "Resend verification email",
"verification_email_resent": "Verification email sent.",
"loading": "Loading…",
"member_since": "Member since",
"stat_games": "Games",

View file

@ -65,8 +65,28 @@
"create_account": "Créer un compte",
"account_title": "Compte",
"label_username": "Nom d'utilisateur",
"label_username_or_email": "Nom d'utilisateur ou email",
"label_password": "Mot de passe",
"label_confirm_password": "Confirmer le mot de passe",
"passwords_do_not_match": "Les mots de passe ne correspondent pas.",
"label_email": "Email",
"forgot_password_link": "Mot de passe oublié ?",
"forgot_password_title": "Réinitialiser le mot de passe",
"forgot_password_email_label": "Adresse email",
"forgot_password_submit": "Envoyer le lien",
"forgot_password_sent": "Si un compte avec cet email existe, un lien de réinitialisation a été envoyé à cette adresse.",
"reset_password_title": "Nouveau mot de passe",
"new_password_label": "Nouveau mot de passe",
"reset_password_submit": "Réinitialiser",
"reset_password_success": "Mot de passe réinitialisé. Vous pouvez maintenant vous connecter.",
"reset_password_invalid": "Ce lien est invalide ou a expiré.",
"verify_email_title": "Vérification de l'email",
"verify_email_checking": "Vérification en cours…",
"verify_email_success": "Votre email a été vérifié.",
"verify_email_invalid": "Ce lien de vérification est invalide ou a expiré.",
"email_not_verified_banner": "Veuillez vérifier votre adresse email — consultez votre boîte de réception.",
"resend_verification": "Renvoyer l'email de vérification",
"verification_email_resent": "Email de vérification envoyé.",
"loading": "Chargement…",
"member_since": "Membre depuis",
"stat_games": "Parties",

View file

@ -15,6 +15,8 @@ fn url(path: &str) -> String {
pub struct MeResponse {
pub id: i64,
pub username: String,
#[serde(default)]
pub email_verified: bool,
}
#[derive(Clone, Debug, Deserialize)]
@ -180,6 +182,66 @@ pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
}
}
pub async fn get_verify_email(token: &str) -> Result<(), String> {
let resp = gloo_net::http::Request::get(&url(&format!("/auth/verify-email?token={token}")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_resend_verification() -> Result<(), String> {
let resp = gloo_net::http::Request::post(&url("/auth/resend-verification"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn post_forgot_password(email: &str) -> Result<(), String> {
let body = serde_json::json!({ "email": email });
let resp = gloo_net::http::Request::post(&url("/auth/forgot-password"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(), String> {
let body = serde_json::json!({ "token": token, "new_password": new_password });
let resp = gloo_net::http::Request::post(&url("/auth/reset-password"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
Ok(())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
// ── Utilities ─────────────────────────────────────────────────────────────────
pub fn format_ts(ts: i64) -> String {

View file

@ -21,7 +21,13 @@ use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStag
use crate::i18n::*;
use crate::nav::SiteNav;
use crate::portal::{
account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage,
account::AccountPage,
forgot_password::ForgotPasswordPage,
game_detail::GameDetailPage,
lobby::LobbyPage,
profile::ProfilePage,
reset_password::ResetPasswordPage,
verify_email::VerifyEmailPage,
};
use trictrac_store::CheckerMove;
@ -145,10 +151,13 @@ pub fn App() -> impl IntoView {
// Auth: fetch once on load; shared by nav + game + portal components.
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
let auth_email_verified: RwSignal<bool> = RwSignal::new(false);
provide_context(auth_username);
provide_context(auth_email_verified);
spawn_local(async move {
if let Ok(me) = api::get_me().await {
auth_username.set(Some(me.username));
auth_email_verified.set(me.email_verified);
}
});
@ -366,6 +375,9 @@ pub fn App() -> impl IntoView {
<Route path=path!("/account") view=AccountPage />
<Route path=path!("/profile/:username") view=ProfilePage />
<Route path=path!("/games/:id") view=GameDetailPage />
<Route path=path!("/verify-email") view=VerifyEmailPage />
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
<Route path=path!("/reset-password") view=ResetPasswordPage />
</Routes>
</main>

View file

@ -9,11 +9,16 @@ pub fn AccountPage() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified =
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
let navigate = use_navigate();
// Only redirect to profile when the email is actually verified.
Effect::new(move |_| {
if let Some(u) = auth_username.get() {
navigate(&format!("/profile/{u}"), Default::default());
if auth_email_verified.get() {
navigate(&format!("/profile/{u}"), Default::default());
}
}
});
@ -25,34 +30,88 @@ pub fn AccountPage() -> impl IntoView {
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
{t!(i18n, account_title)}
</h1>
<div class="portal-tabs">
<button
class=move || if tab.get() == "login" { "portal-tab-btn active" } else { "portal-tab-btn" }
on:click=move |_| tab.set("login")
>{t!(i18n, sign_in)}</button>
<button
class=move || if tab.get() == "register" { "portal-tab-btn active" } else { "portal-tab-btn" }
on:click=move |_| tab.set("register")
>{t!(i18n, create_account)}</button>
</div>
{move || if tab.get() == "login" {
view! { <LoginForm /> }.into_any()
} else {
view! { <RegisterForm /> }.into_any()
{move || {
let username = auth_username.get();
let verified = auth_email_verified.get();
if username.is_some() && !verified {
view! { <VerificationBanner /> }.into_any()
} else if username.is_none() {
view! {
<div>
<div class="portal-tabs">
<button
class=move || if tab.get() == "login" { "portal-tab-btn active" } else { "portal-tab-btn" }
on:click=move |_| tab.set("login")
>{t!(i18n, sign_in)}</button>
<button
class=move || if tab.get() == "register" { "portal-tab-btn active" } else { "portal-tab-btn" }
on:click=move |_| tab.set("register")
>{t!(i18n, create_account)}</button>
</div>
{move || if tab.get() == "login" {
view! { <LoginForm /> }.into_any()
} else {
view! { <RegisterForm /> }.into_any()
}}
</div>
}.into_any()
} else {
view! { <span /> }.into_any()
}
}}
</div>
</div>
}
}
#[component]
fn VerificationBanner() -> impl IntoView {
let i18n = use_i18n();
let pending = RwSignal::new(false);
let sent = RwSignal::new(false);
let error = RwSignal::new(String::new());
let resend = move |_| {
if pending.get() { return; }
pending.set(true);
sent.set(false);
error.set(String::new());
wasm_bindgen_futures::spawn_local(async move {
match api::post_resend_verification().await {
Ok(()) => { sent.set(true); }
Err(e) => { error.set(e); }
}
pending.set(false);
});
};
view! {
<div class="portal-verification-banner">
<p>{t!(i18n, email_not_verified_banner)}</p>
<button class="portal-submit-btn" on:click=resend disabled=move || pending.get()>
{t!(i18n, resend_verification)}
</button>
{move || if sent.get() {
view! { <p class="portal-success">{ t_string!(i18n, verification_email_resent).to_string() }</p> }.into_any()
} else if !error.get().is_empty() {
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</div>
}
}
#[component]
fn LoginForm() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified =
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
let navigate = use_navigate();
let username = RwSignal::new(String::new());
let login = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
@ -62,15 +121,18 @@ fn LoginForm() -> impl IntoView {
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let u = username.get();
let u = login.get();
let p = password.get();
let navigate = navigate.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::post_login(&u, &p).await {
Ok(me) => {
let dest = format!("/profile/{}", me.username);
auth_username.set(Some(me.username));
navigate(&dest, Default::default());
auth_username.set(Some(me.username.clone()));
auth_email_verified.set(me.email_verified);
if me.email_verified {
navigate(&format!("/profile/{}", me.username), Default::default());
}
// If not verified, the AccountPage Effect will show the banner.
}
Err(e) => {
let msg = if e.is_empty() {
@ -87,14 +149,17 @@ fn LoginForm() -> impl IntoView {
view! {
<form on:submit=submit>
<label class="portal-label">{t!(i18n, label_username)}</label>
<input class="portal-input" type="text" required
prop:value=move || username.get()
on:input=move |ev| username.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_username_or_email)}</label>
<input class="portal-input" type="text" required autocomplete="username"
prop:value=move || login.get()
on:input=move |ev| login.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_password)}</label>
<input class="portal-input" type="password" required
<input class="portal-input" type="password" required autocomplete="current-password"
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<div style="text-align:right;margin-bottom:0.75rem">
<a href="/forgot-password" class="portal-link">{t!(i18n, forgot_password_link)}</a>
</div>
<button class="portal-submit-btn" type="submit"
disabled=move || pending.get()
>{t!(i18n, sign_in)}</button>
@ -112,29 +177,36 @@ fn RegisterForm() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let navigate = use_navigate();
let auth_email_verified =
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
let username = RwSignal::new(String::new());
let email = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let confirm_password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
if password.get() != confirm_password.get() {
error.set(t_string!(i18n, passwords_do_not_match).to_string());
return;
}
pending.set(true);
error.set(String::new());
let u = username.get();
let e = email.get();
let p = password.get();
let navigate = navigate.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::post_register(&u, &e, &p).await {
Ok(me) => {
let dest = format!("/profile/{}", me.username);
auth_username.set(Some(me.username));
navigate(&dest, Default::default());
auth_email_verified.set(me.email_verified);
// AccountPage shows verification banner when email_verified = false.
}
Err(err) => {
error.set(err);
@ -147,17 +219,21 @@ fn RegisterForm() -> impl IntoView {
view! {
<form on:submit=submit>
<label class="portal-label">{t!(i18n, label_username)}</label>
<input class="portal-input" type="text" required
<input class="portal-input" type="text" required autocomplete="username"
prop:value=move || username.get()
on:input=move |ev| username.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_email)}</label>
<input class="portal-input" type="email" required
<input class="portal-input" type="email" required autocomplete="email"
prop:value=move || email.get()
on:input=move |ev| email.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_password)}</label>
<input class="portal-input" type="password" required
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || confirm_password.get()
on:input=move |ev| confirm_password.set(event_target_value(&ev)) />
<button class="portal-submit-btn" type="submit"
disabled=move || pending.get()
>{t!(i18n, create_account)}</button>

View file

@ -0,0 +1,66 @@
use leptos::prelude::*;
use crate::api;
use crate::i18n::*;
#[component]
pub fn ForgotPasswordPage() -> impl IntoView {
let i18n = use_i18n();
let email = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let sent = RwSignal::new(false);
let error = RwSignal::new(String::new());
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let e = email.get();
wasm_bindgen_futures::spawn_local(async move {
match api::post_forgot_password(&e).await {
Ok(()) => { sent.set(true); }
Err(e) => { error.set(e); }
}
pending.set(false);
});
};
view! {
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
<div class="portal-card" style="max-width:420px;width:100%">
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
{t!(i18n, forgot_password_title)}
</h1>
{move || if sent.get() {
view! {
<p class="portal-success" style="text-align:center">
{t!(i18n, forgot_password_sent)}
</p>
}.into_any()
} else {
view! {
<form on:submit=submit>
<label class="portal-label">{t!(i18n, forgot_password_email_label)}</label>
<input class="portal-input" type="email" required autocomplete="email"
prop:value=move || email.get()
on:input=move |ev| email.set(event_target_value(&ev)) />
<button class="portal-submit-btn" type="submit"
disabled=move || pending.get()
>{t!(i18n, forgot_password_submit)}</button>
{move || if !error.get().is_empty() {
view! { <p class="portal-error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}.into_any()
}}
<div style="margin-top:1rem;text-align:center">
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
</div>
</div>
</div>
}
}

View file

@ -1,4 +1,7 @@
pub mod account;
pub mod forgot_password;
pub mod game_detail;
pub mod lobby;
pub mod profile;
pub mod reset_password;
pub mod verify_email;

View file

@ -0,0 +1,87 @@
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use crate::api;
use crate::i18n::*;
#[component]
pub fn ResetPasswordPage() -> impl IntoView {
let i18n = use_i18n();
let query = use_query_map();
// Read token once — not reactive, just a plain String.
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
let new_password = RwSignal::new(String::new());
let confirm_password = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let success = RwSignal::new(false);
let error = RwSignal::new(String::new());
if token.is_empty() {
error.set(t_string!(i18n, reset_password_invalid).to_string());
}
// `submit` moves `token: String` — it is FnMut (clones token each call) but not Copy.
// Keep it off of reactive closures: put it directly on <form on:submit=submit>.
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
if new_password.get() != confirm_password.get() {
error.set(t_string!(i18n, passwords_do_not_match).to_string());
return;
}
pending.set(true);
error.set(String::new());
let tok = token.clone();
let pw = new_password.get();
let invalid_msg = t_string!(i18n, reset_password_invalid).to_string();
wasm_bindgen_futures::spawn_local(async move {
match api::post_reset_password(&tok, &pw).await {
Ok(()) => { success.set(true); }
Err(_) => { error.set(invalid_msg); }
}
pending.set(false);
});
};
view! {
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
<div class="portal-card" style="max-width:420px;width:100%">
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem;text-align:center">
{t!(i18n, reset_password_title)}
</h1>
// Success message — only captures `success` (Copy RwSignal)
{move || success.get().then(|| view! {
<p class="portal-success" style="text-align:center">
{t!(i18n, reset_password_success)}
</p>
<div style="margin-top:1rem;text-align:center">
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
</div>
})}
// Form — `submit` lives directly on the element, not inside a reactive closure
<form on:submit=submit
style:display=move || if success.get() { "none" } else { "" }>
<label class="portal-label">{t!(i18n, new_password_label)}</label>
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || new_password.get()
on:input=move |ev| new_password.set(event_target_value(&ev)) />
<label class="portal-label">{t!(i18n, label_confirm_password)}</label>
<input class="portal-input" type="password" required autocomplete="new-password"
prop:value=move || confirm_password.get()
on:input=move |ev| confirm_password.set(event_target_value(&ev)) />
<button class="portal-submit-btn" type="submit"
prop:disabled=move || pending.get()
>{t!(i18n, reset_password_submit)}</button>
{move || (!error.get().is_empty()).then(|| view! {
<p class="portal-error">{ error.get() }</p>
})}
</form>
</div>
</div>
}
}

View file

@ -0,0 +1,83 @@
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use crate::api;
use crate::i18n::*;
#[derive(Clone, PartialEq)]
enum VerifyStatus {
Checking,
Success,
Error,
}
#[component]
pub fn VerifyEmailPage() -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
let auth_email_verified =
use_context::<RwSignal<bool>>().expect("auth_email_verified context not found");
let query = use_query_map();
let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());
let status = RwSignal::new(VerifyStatus::Checking);
let tok = token.clone();
wasm_bindgen_futures::spawn_local(async move {
let s = if tok.is_empty() {
VerifyStatus::Error
} else {
match api::get_verify_email(&tok).await {
Ok(()) => {
// Update the current session if the user is already logged in.
auth_email_verified.set(true);
VerifyStatus::Success
}
Err(_) => VerifyStatus::Error,
}
};
status.set(s);
});
let profile_href = move || {
auth_username
.get()
.map(|u| format!("/profile/{u}"))
.unwrap_or_else(|| "/account".to_string())
};
view! {
<div class="portal-main" style="display:flex;justify-content:center;padding-top:3rem">
<div class="portal-card" style="max-width:420px;width:100%;text-align:center">
<h1 style="font-family:var(--font-display);font-size:1.6rem;margin-bottom:1.5rem">
{t!(i18n, verify_email_title)}
</h1>
{move || match status.get() {
VerifyStatus::Checking => view! {
<p class="portal-empty">{t!(i18n, verify_email_checking)}</p>
}.into_any(),
VerifyStatus::Success => view! {
<div>
<p class="portal-success">{t!(i18n, verify_email_success)}</p>
<div style="margin-top:1rem">
<a href=profile_href class="portal-link">
{t!(i18n, sign_in)}
</a>
</div>
</div>
}.into_any(),
VerifyStatus::Error => view! {
<div>
<p class="portal-error">{t!(i18n, verify_email_invalid)}</p>
<div style="margin-top:1rem">
<a href="/account" class="portal-link">{t!(i18n, sign_in)}</a>
</div>
</div>
}.into_any(),
}}
</div>
</div>
}
}