diff --git a/Cargo.lock b/Cargo.lock index 07b7830..f5ef385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 8031d70..3b12bdb 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -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; diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 8ff3548..2d7ca43 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -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", diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 2346395..3ff78aa 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -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", diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs index 29032c0..9e0f57c 100644 --- a/clients/web/src/api.rs +++ b/clients/web/src/api.rs @@ -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 { } } +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 { diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 7f17a51..5aa0f80 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -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> = RwSignal::new(None); + let auth_email_verified: RwSignal = 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 { + + + diff --git a/clients/web/src/portal/account.rs b/clients/web/src/portal/account.rs index 2250bc3..842338d 100644 --- a/clients/web/src/portal/account.rs +++ b/clients/web/src/portal/account.rs @@ -9,11 +9,16 @@ pub fn AccountPage() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); + let auth_email_verified = + use_context::>().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 {

{t!(i18n, account_title)}

-
- - -
- {move || if tab.get() == "login" { - view! { }.into_any() - } else { - view! { }.into_any() + {move || { + let username = auth_username.get(); + let verified = auth_email_verified.get(); + if username.is_some() && !verified { + view! { }.into_any() + } else if username.is_none() { + view! { +
+
+ + +
+ {move || if tab.get() == "login" { + view! { }.into_any() + } else { + view! { }.into_any() + }} +
+ }.into_any() + } else { + view! { }.into_any() + } }} } } +#[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! { +
+

{t!(i18n, email_not_verified_banner)}

+ + {move || if sent.get() { + view! {

{ t_string!(i18n, verification_email_resent).to_string() }

}.into_any() + } else if !error.get().is_empty() { + view! {

{ error.get() }

}.into_any() + } else { + view! { }.into_any() + }} +
+ } +} + #[component] fn LoginForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); + let auth_email_verified = + use_context::>().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! {
- - + + - + @@ -112,29 +177,36 @@ fn RegisterForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let navigate = use_navigate(); + let auth_email_verified = + use_context::>().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! { - - - + + diff --git a/clients/web/src/portal/forgot_password.rs b/clients/web/src/portal/forgot_password.rs new file mode 100644 index 0000000..6a4f39e --- /dev/null +++ b/clients/web/src/portal/forgot_password.rs @@ -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! { +
+
+

+ {t!(i18n, forgot_password_title)} +

+ {move || if sent.get() { + view! { +

+ {t!(i18n, forgot_password_sent)} +

+ }.into_any() + } else { + view! { + + + + + {move || if !error.get().is_empty() { + view! {

{ error.get() }

}.into_any() + } else { + view! { }.into_any() + }} + + }.into_any() + }} + +
+
+ } +} diff --git a/clients/web/src/portal/mod.rs b/clients/web/src/portal/mod.rs index 722c9e1..a270b5f 100644 --- a/clients/web/src/portal/mod.rs +++ b/clients/web/src/portal/mod.rs @@ -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; diff --git a/clients/web/src/portal/reset_password.rs b/clients/web/src/portal/reset_password.rs new file mode 100644 index 0000000..d2d8fa8 --- /dev/null +++ b/clients/web/src/portal/reset_password.rs @@ -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
. + 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! { +
+
+

+ {t!(i18n, reset_password_title)} +

+ + // Success message — only captures `success` (Copy RwSignal) + {move || success.get().then(|| view! { +

+ {t!(i18n, reset_password_success)} +

+ + })} + + // Form — `submit` lives directly on the element, not inside a reactive closure + + + + + + + {move || (!error.get().is_empty()).then(|| view! { +

{ error.get() }

+ })} + +
+
+ } +} diff --git a/clients/web/src/portal/verify_email.rs b/clients/web/src/portal/verify_email.rs new file mode 100644 index 0000000..0ce0cae --- /dev/null +++ b/clients/web/src/portal/verify_email.rs @@ -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::>>().expect("auth_username context not found"); + let auth_email_verified = + use_context::>().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! { +
+
+

+ {t!(i18n, verify_email_title)} +

+ {move || match status.get() { + VerifyStatus::Checking => view! { +

{t!(i18n, verify_email_checking)}

+ }.into_any(), + VerifyStatus::Success => view! { +
+

{t!(i18n, verify_email_success)}

+ +
+ }.into_any(), + VerifyStatus::Error => view! { +
+

{t!(i18n, verify_email_invalid)}

+ +
+ }.into_any(), + }} +
+
+ } +} diff --git a/devenv.nix b/devenv.nix index b2467ab..b1e5fb5 100644 --- a/devenv.nix +++ b/devenv.nix @@ -34,6 +34,12 @@ in initialDatabases = [{ name = "trictrac"; user = "trictrac"; pass = "trictrac"; }]; }; + services = { + mailpit = { + enable = true; + }; + }; + # https://devenv.sh/languages/ languages.rust.enable = true; diff --git a/server/relay-server/Cargo.toml b/server/relay-server/Cargo.toml index 8ff17bd..b4312d5 100644 --- a/server/relay-server/Cargo.toml +++ b/server/relay-server/Cargo.toml @@ -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"] } diff --git a/server/relay-server/migrations/003_email_verification.sql b/server/relay-server/migrations/003_email_verification.sql new file mode 100644 index 0000000..bd04f92 --- /dev/null +++ b/server/relay-server/migrations/003_email_verification.sql @@ -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); diff --git a/server/relay-server/src/auth.rs b/server/relay-server/src/auth.rs index f252f11..0142c2c 100644 --- a/server/relay-server/src/auth.rs +++ b/server/relay-server/src/auth.rs @@ -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, 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); }; diff --git a/server/relay-server/src/db.rs b/server/relay-server/src/db.rs index 9f64a1f..83b9f25 100644 --- a/server/relay-server/src/db.rs +++ b/server/relay-server/src/db.rs @@ -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, 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, 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, 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, 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, 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 + } })) } diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs index 832f65d..9c6071f 100644 --- a/server/relay-server/src/http.rs +++ b/server/relay-server/src/http.rs @@ -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> { @@ -34,12 +44,26 @@ pub fn router() -> Router> { .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 for GameSummaryResponse { } } -// ── Handlers ────────────────────────────────────────────────────────────────── +// ── Auth handlers ───────────────────────────────────────────────────────────── async fn register( mut auth_session: AuthSession, @@ -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, ) -> Result { 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) -> Result 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>, + Query(params): Query, +) -> Result { + let user_id = db::consume_email_token(&state.db, ¶ms.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, + State(state): State>, +) -> Result { + 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>, + Json(body): Json, +) -> 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>, + Json(body): Json, +) -> Result { + 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, State(state): State>, @@ -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 { 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)?; diff --git a/server/relay-server/src/lobby.rs b/server/relay-server/src/lobby.rs index 664f809..db1c4f8 100644 --- a/server/relay-server/src/lobby.rs +++ b/server/relay-server/src/lobby.rs @@ -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>, /// 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, } } } diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs index 70fde5e..0dfea0c 100644 --- a/server/relay-server/src/main.rs +++ b/server/relay-server/src/main.rs @@ -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 diff --git a/server/relay-server/src/smtp.rs b/server/relay-server/src/smtp.rs new file mode 100644 index 0000000..37ebaa3 --- /dev/null +++ b/server/relay-server/src/smtp.rs @@ -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, + 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::::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}"); + } + } +}