feat: add email verification & password reset link
This commit is contained in:
parent
440bf12c43
commit
614be65c90
20 changed files with 929 additions and 62 deletions
59
Cargo.lock
generated
59
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
66
clients/web/src/portal/forgot_password.rs
Normal file
66
clients/web/src/portal/forgot_password.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
87
clients/web/src/portal/reset_password.rs
Normal file
87
clients/web/src/portal/reset_password.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
83
clients/web/src/portal/verify_email.rs
Normal file
83
clients/web/src/portal/verify_email.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,12 @@ in
|
|||
initialDatabases = [{ name = "trictrac"; user = "trictrac"; pass = "trictrac"; }];
|
||||
};
|
||||
|
||||
services = {
|
||||
mailpit = {
|
||||
enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
# https://devenv.sh/languages/
|
||||
languages.rust.enable = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
12
server/relay-server/migrations/003_email_verification.sql
Normal file
12
server/relay-server/migrations/003_email_verification.sql
Normal 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);
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¶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<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)?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
server/relay-server/src/smtp.rs
Normal file
99
server/relay-server/src/smtp.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue