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 d24f850882
20 changed files with 928 additions and 62 deletions

59
Cargo.lock generated
View file

@ -2723,6 +2723,22 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "embassy-futures"
version = "0.1.2"
@ -3875,6 +3891,17 @@ dependencies = [
"digest 0.11.2",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "html-escape"
version = "0.2.13"
@ -5004,6 +5031,31 @@ dependencies = [
"tachys",
]
[[package]]
name = "lettre"
version = "0.11.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
dependencies = [
"async-trait",
"base64 0.22.1",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"nom 8.0.0",
"percent-encoding",
"quoted_printable",
"socket2",
"tokio",
"url",
]
[[package]]
name = "libc"
version = "0.2.184"
@ -6435,6 +6487,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
@ -6878,6 +6936,7 @@ dependencies = [
"bytes",
"deadpool-postgres",
"futures-util",
"lettre",
"postcard",
"protocol",
"rand 0.8.5",

View file

@ -292,6 +292,25 @@ 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;
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>
}
}

View file

@ -34,6 +34,12 @@ in
initialDatabases = [{ name = "trictrac"; user = "trictrac"; pass = "trictrac"; }];
};
services = {
mailpit = {
enable = true;
};
};
# https://devenv.sh/languages/
languages.rust.enable = true;

View file

@ -25,3 +25,4 @@ axum-login = "0.18"
argon2 = "0.5"
time = "0.3"
thiserror = "1"
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1", "builder", "hostname"] }

View file

@ -0,0 +1,12 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS email_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
expires_at BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);

View file

@ -30,7 +30,8 @@ impl AuthUser for db::User {
#[derive(Clone)]
pub struct Credentials {
pub username: String,
/// Accepts either a username or an email address.
pub login: String,
pub password: String,
}
@ -66,7 +67,7 @@ impl AuthnBackend for AuthBackend {
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let Some(user) = db::get_user_by_username(&self.pool, &creds.username).await? else {
let Some(user) = db::get_user_by_username_or_email(&self.pool, &creds.login).await? else {
return Ok(None);
};

View file

@ -15,6 +15,7 @@ pub struct User {
pub email: String,
pub password_hash: String,
pub created_at: i64,
pub email_verified: bool,
}
/// Aggregated game statistics for a user's public profile.
@ -54,7 +55,7 @@ impl DbError {
}
}
fn now_unix() -> i64 {
pub fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@ -83,12 +84,27 @@ pub async fn init_db(url: &str) -> Pool {
.batch_execute(include_str!("../migrations/002_participants_unique.sql"))
.await
.expect("Migration 002 failed");
client
.batch_execute(include_str!("../migrations/003_email_verification.sql"))
.await
.expect("Migration 003 failed");
pool
}
// ── Users ────────────────────────────────────────────────────────────────────
fn user_from_row(r: &tokio_postgres::Row) -> User {
User {
id: r.get("id"),
username: r.get("username"),
email: r.get("email"),
password_hash: r.get("password_hash"),
created_at: r.get("created_at"),
email_verified: r.get("email_verified"),
}
}
pub async fn create_user(
pool: &Pool,
username: &str,
@ -98,8 +114,8 @@ pub async fn create_user(
let client = pool.get().await?;
let row = client
.query_one(
"INSERT INTO users (username, email, password_hash, created_at) \
VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO users (username, email, password_hash, created_at, email_verified) \
VALUES ($1, $2, $3, $4, FALSE) RETURNING id",
&[&username, &email, &password_hash, &now_unix()],
)
.await?;
@ -110,33 +126,123 @@ pub async fn get_user_by_id(pool: &Pool, id: i64) -> Result<Option<User>, DbErro
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE id = $1",
&[&id],
)
.await?;
Ok(row.map(|r| User {
id: r.get("id"),
username: r.get("username"),
email: r.get("email"),
password_hash: r.get("password_hash"),
created_at: r.get("created_at"),
}))
Ok(row.as_ref().map(user_from_row))
}
pub async fn get_user_by_username(pool: &Pool, username: &str) -> Result<Option<User>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE username = $1",
&[&username],
)
.await?;
Ok(row.map(|r| User {
id: r.get("id"),
username: r.get("username"),
email: r.get("email"),
password_hash: r.get("password_hash"),
created_at: r.get("created_at"),
Ok(row.as_ref().map(user_from_row))
}
pub async fn get_user_by_email(pool: &Pool, email: &str) -> Result<Option<User>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"SELECT id, username, email, password_hash, created_at, email_verified \
FROM users WHERE email = $1",
&[&email],
)
.await?;
Ok(row.as_ref().map(user_from_row))
}
/// Looks up a user by username first; if not found, tries by email.
pub async fn get_user_by_username_or_email(pool: &Pool, login: &str) -> Result<Option<User>, DbError> {
if let Some(u) = get_user_by_username(pool, login).await? {
return Ok(Some(u));
}
get_user_by_email(pool, login).await
}
pub async fn set_email_verified(pool: &Pool, user_id: i64) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"UPDATE users SET email_verified = TRUE WHERE id = $1",
&[&user_id],
)
.await?;
Ok(())
}
pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"UPDATE users SET password_hash = $1 WHERE id = $2",
&[&hash, &user_id],
)
.await?;
Ok(())
}
// ── Email tokens ──────────────────────────────────────────────────────────────
pub async fn create_email_token(
pool: &Pool,
user_id: i64,
token: &str,
kind: &str,
expires_at: i64,
) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"INSERT INTO email_tokens (user_id, token, kind, expires_at, created_at) \
VALUES ($1, $2, $3, $4, $5)",
&[&user_id, &token, &kind, &expires_at, &now_unix()],
)
.await?;
Ok(())
}
/// Removes all tokens of the given kind for a user (call before creating a fresh one).
pub async fn delete_email_tokens(pool: &Pool, user_id: i64, kind: &str) -> Result<(), DbError> {
let client = pool.get().await?;
client
.execute(
"DELETE FROM email_tokens WHERE user_id = $1 AND kind = $2",
&[&user_id, &kind],
)
.await?;
Ok(())
}
/// Atomically deletes the token row and returns the `user_id` if the token
/// exists and has not expired. Returns `None` for missing or expired tokens.
pub async fn consume_email_token(
pool: &Pool,
token: &str,
kind: &str,
) -> Result<Option<i64>, DbError> {
let client = pool.get().await?;
let row = client
.query_opt(
"DELETE FROM email_tokens WHERE token = $1 AND kind = $2 \
RETURNING user_id, expires_at",
&[&token, &kind],
)
.await?;
Ok(row.and_then(|r| {
let expires_at: i64 = r.get("expires_at");
if expires_at >= now_unix() {
Some(r.get("user_id"))
} else {
None
}
}))
}

View file

@ -1,12 +1,17 @@
//! HTTP endpoints for user management (Phases 2 & 4).
//! HTTP endpoints for user management.
//!
//! Routes:
//! POST /auth/register
//! POST /auth/login
//! POST /auth/logout
//! GET /auth/me
//! GET /auth/verify-email?token=…
//! POST /auth/resend-verification
//! POST /auth/forgot-password
//! POST /auth/reset-password
//! GET /users/:username
//! GET /users/:username/games?page=0&per_page=20
//! GET /games/:id
//! POST /games/result
use axum::{
@ -17,15 +22,20 @@ use axum::{
routing::{get, post},
};
use axum_login::AuthSession;
use rand::distributions::Alphanumeric;
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::sync::Arc;
use crate::auth::{AuthBackend, Credentials, hash_password};
use crate::db;
use crate::db::{self, now_unix};
use crate::lobby::AppState;
const VERIFY_TOKEN_EXPIRY: i64 = 86_400; // 24 hours
const RESET_TOKEN_EXPIRY: i64 = 3_600; // 1 hour
// ── Router ────────────────────────────────────────────────────────────────────
pub fn router() -> Router<Arc<AppState>> {
@ -34,12 +44,26 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/auth/login", post(login))
.route("/auth/logout", post(logout))
.route("/auth/me", get(me))
.route("/auth/verify-email", get(verify_email))
.route("/auth/resend-verification", post(resend_verification))
.route("/auth/forgot-password", post(forgot_password))
.route("/auth/reset-password", post(reset_password))
.route("/users/{username}", get(user_profile))
.route("/users/{username}/games", get(user_games))
.route("/games/result", post(game_result))
.route("/games/{id}", get(game_detail))
}
// ── Token generation ──────────────────────────────────────────────────────────
fn generate_token() -> String {
rand::thread_rng()
.sample_iter(Alphanumeric)
.take(64)
.map(char::from)
.collect()
}
// ── Error type ────────────────────────────────────────────────────────────────
enum AppError {
@ -88,10 +112,27 @@ struct LoginBody {
password: String,
}
#[derive(Deserialize)]
struct TokenQuery {
token: String,
}
#[derive(Deserialize)]
struct ForgotPasswordBody {
email: String,
}
#[derive(Deserialize)]
struct ResetPasswordBody {
token: String,
new_password: String,
}
#[derive(Serialize)]
struct MeResponse {
id: i64,
username: String,
email_verified: bool,
}
#[derive(Serialize)]
@ -147,7 +188,7 @@ impl From<db::GameSummary> for GameSummaryResponse {
}
}
// ── Handlers ──────────────────────────────────────────────────────────────────
// ── Auth handlers ─────────────────────────────────────────────────────────────
async fn register(
mut auth_session: AuthSession<AuthBackend>,
@ -180,6 +221,16 @@ async fn register(
.await?
.ok_or(AppError::Internal)?;
// Send verification email (best-effort).
let token = generate_token();
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
if db::create_email_token(&state.db, user_id, &token, "verify", expires_at)
.await
.is_ok()
{
state.mailer.send_verification(&body.email, &token).await;
}
auth_session.login(&user).await.map_err(|_| AppError::Internal)?;
Ok((
@ -187,6 +238,7 @@ async fn register(
Json(MeResponse {
id: user.id,
username: user.username,
email_verified: user.email_verified,
}),
))
}
@ -196,7 +248,7 @@ async fn login(
Json(body): Json<LoginBody>,
) -> Result<impl IntoResponse, AppError> {
let creds = Credentials {
username: body.username,
login: body.username,
password: body.password,
};
@ -211,6 +263,7 @@ async fn login(
Ok(Json(MeResponse {
id: user.id,
username: user.username,
email_verified: user.email_verified,
}))
}
@ -224,12 +277,86 @@ async fn me(auth_session: AuthSession<AuthBackend>) -> Result<impl IntoResponse,
Some(user) => Ok(Json(MeResponse {
id: user.id,
username: user.username,
email_verified: user.email_verified,
})
.into_response()),
None => Ok(StatusCode::UNAUTHORIZED.into_response()),
}
}
async fn verify_email(
State(state): State<Arc<AppState>>,
Query(params): Query<TokenQuery>,
) -> Result<StatusCode, AppError> {
let user_id = db::consume_email_token(&state.db, &params.token, "verify")
.await?
.ok_or(AppError::BadRequest("invalid or expired token"))?;
db::set_email_verified(&state.db, user_id).await?;
Ok(StatusCode::OK)
}
async fn resend_verification(
auth_session: AuthSession<AuthBackend>,
State(state): State<Arc<AppState>>,
) -> Result<StatusCode, AppError> {
let user = auth_session.user.ok_or(AppError::Unauthorized)?;
if user.email_verified {
return Ok(StatusCode::OK);
}
db::delete_email_tokens(&state.db, user.id, "verify").await?;
let token = generate_token();
let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY;
db::create_email_token(&state.db, user.id, &token, "verify", expires_at).await?;
state.mailer.send_verification(&user.email, &token).await;
Ok(StatusCode::OK)
}
async fn forgot_password(
State(state): State<Arc<AppState>>,
Json(body): Json<ForgotPasswordBody>,
) -> StatusCode {
// Always return 200 to avoid leaking which email addresses are registered.
if let Ok(Some(user)) = db::get_user_by_email(&state.db, &body.email).await {
let _ = db::delete_email_tokens(&state.db, user.id, "reset").await;
let token = generate_token();
let expires_at = now_unix() + RESET_TOKEN_EXPIRY;
if db::create_email_token(&state.db, user.id, &token, "reset", expires_at)
.await
.is_ok()
{
state.mailer.send_password_reset(&body.email, &token).await;
}
}
StatusCode::OK
}
async fn reset_password(
State(state): State<Arc<AppState>>,
Json(body): Json<ResetPasswordBody>,
) -> Result<StatusCode, AppError> {
if body.new_password.len() < 8 {
return Err(AppError::BadRequest("password must be at least 8 characters"));
}
let user_id = db::consume_email_token(&state.db, &body.token, "reset")
.await?
.ok_or(AppError::BadRequest("invalid or expired token"))?;
let hash = hash_password(&body.new_password).map_err(|_| AppError::Internal)?;
db::update_password_hash(&state.db, user_id, &hash).await?;
Ok(StatusCode::OK)
}
// ── Profile handlers ──────────────────────────────────────────────────────────
async fn user_profile(
Path(username): Path<String>,
State(state): State<Arc<AppState>>,
@ -270,7 +397,7 @@ async fn user_games(
}))
}
// ── Game detail (Phase 5) ─────────────────────────────────────────────────────
// ── Game detail ───────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ParticipantWithUsername {
@ -338,7 +465,7 @@ async fn game_detail(
}))
}
// ── Game result recording (Phase 4) ──────────────────────────────────────────
// ── Game result recording ─────────────────────────────────────────────────────
#[derive(Deserialize)]
struct GameResultBody {
@ -368,7 +495,6 @@ async fn game_result(
) -> Result<impl IntoResponse, AppError> {
let compound_id = format!("{}#{}", body.room_code, body.game_id);
// Snapshot the fields we need while holding the lock, then release immediately.
let (game_record_id, user_ids) = {
let rooms = state.rooms.lock().await;
let room = rooms.get(&compound_id).ok_or(AppError::NotFound)?;

View file

@ -13,6 +13,8 @@ use tokio::fs;
use tokio::sync::{Mutex, RwLock};
use tokio::sync::{broadcast, mpsc};
use crate::smtp::Mailer;
/// The game entry we have for one game.
#[derive(Serialize, Deserialize)]
pub struct GameEntry {
@ -59,14 +61,17 @@ pub struct AppState {
pub configs: RwLock<HashMap<String, u16>>,
/// PostgreSQL connection pool — shared across all request handlers.
pub db: Pool,
/// SMTP mailer for email verification and password reset.
pub mailer: Mailer,
}
impl AppState {
pub fn new(db: Pool) -> Self {
pub fn new(db: Pool, mailer: Mailer) -> Self {
Self {
rooms: Mutex::new(HashMap::new()),
configs: RwLock::new(HashMap::new()),
db,
mailer,
}
}
}

View file

@ -4,6 +4,7 @@ mod hand_shake;
mod http;
mod lobby;
mod message_relay;
mod smtp;
use crate::auth::AuthBackend;
use crate::hand_shake::{
@ -55,6 +56,8 @@ async fn main() {
.unwrap_or_else(|_| "postgresql://trictrac:trictrac@127.0.0.1:5432/trictrac".to_string());
let pool = db::init_db(&database_url).await;
let mailer = smtp::Mailer::from_env();
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
@ -63,7 +66,7 @@ async fn main() {
let auth_backend = AuthBackend::new(pool.clone());
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
let app_state = Arc::new(AppState::new(pool));
let app_state = Arc::new(AppState::new(pool, mailer));
let watchdog_state = app_state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min

View file

@ -0,0 +1,99 @@
//! SMTP mailer (plain SMTP, no TLS).
//!
//! Configured via environment variables:
//! SMTP_HOST — default: 127.0.0.1 (mailpit in dev)
//! SMTP_PORT — default: 1025 (mailpit default)
//! SMTP_FROM — default: noreply@trictrac.local
//! SMTP_USER — optional SMTP credentials
//! SMTP_PASSWORD — optional SMTP credentials
//! APP_URL — default: http://localhost:9091 (frontend base URL for email links)
//!
//! For production with TLS, run a local relay (e.g. Postfix) or use an SMTP
//! service that accepts plain connections on a local port and handles TLS externally.
use lettre::{
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
message::Mailbox,
transport::smtp::authentication::Credentials as SmtpCredentials,
};
pub struct Mailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: Mailbox,
app_url: String,
}
impl Mailer {
pub fn from_env() -> Self {
let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(1025);
let from_str = std::env::var("SMTP_FROM")
.unwrap_or_else(|_| "noreply@trictrac.local".to_string());
let app_url = std::env::var("APP_URL")
.unwrap_or_else(|_| "http://localhost:9091".to_string());
let mut builder =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host).port(port);
if let (Ok(user), Ok(pass)) = (std::env::var("SMTP_USER"), std::env::var("SMTP_PASSWORD")) {
builder = builder.credentials(SmtpCredentials::new(user, pass));
}
let transport = builder.build();
let from = from_str
.parse()
.unwrap_or_else(|_| "noreply@trictrac.local".parse().unwrap());
Self { transport, from, app_url }
}
pub async fn send_verification(&self, to_email: &str, token: &str) {
let link = format!("{}/verify-email?token={}", self.app_url, token);
let body = format!(
"Welcome to Trictrac!\n\n\
Please verify your email address by clicking the link below:\n\n\
{link}\n\n\
This link expires in 24 hours.\n"
);
self.send(to_email, "Verify your Trictrac account", body).await;
}
pub async fn send_password_reset(&self, to_email: &str, token: &str) {
let link = format!("{}/reset-password?token={}", self.app_url, token);
let body = format!(
"You requested a password reset for your Trictrac account.\n\n\
Click the link below to choose a new password:\n\n\
{link}\n\n\
This link expires in 1 hour.\n\
If you did not request this, you can safely ignore this email.\n"
);
self.send(to_email, "Reset your Trictrac password", body).await;
}
async fn send(&self, to_email: &str, subject: &str, body: String) {
let to: Mailbox = match to_email.parse() {
Ok(m) => m,
Err(e) => {
tracing::warn!("SMTP: invalid recipient address {to_email:?}: {e}");
return;
}
};
let email = match Message::builder()
.from(self.from.clone())
.to(to)
.subject(subject)
.body(body)
{
Ok(e) => e,
Err(e) => {
tracing::warn!("SMTP: failed to build message: {e}");
return;
}
};
if let Err(e) = self.transport.send(email).await {
tracing::warn!("SMTP: send failed: {e}");
}
}
}