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
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue