A political conference and discussion platform, in Rust and Dioxus
Something went wrong. Try again.
31 kB · 671 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672use dioxus::prelude::*;
use crate::i18n::t;use crate::nhost;use crate::route::Route;use crate::session::{expires_at_from, save_session, Session, User, SESSION};
#[derive(Clone, PartialEq)]enum AuthMode { Login, Register, ResetPassword, SetPassword,}
/// Shortest value the sign-up schema accepts for a name or a password.////// Checked here so a value below it is answered under the box it belongs to, in/// the user's language, without a round trip. The service enforces it too, but/// as `schema-validation-error` carrying "minimum string length is 3" and naming/// no field, which is not something a form can point at.const MIN_FIELD_LEN: usize = 3;
/// A localized message for an auth failure.////// The service answers in English with a machine code beside it. The CODE is/// what carries the meaning, so translate that and never show the service's own/// sentence: an unmapped one used to reach the screen verbatim, which is how a/// Danish user got "Password is too short" under a Danish label.////// An unmapped code shows the app's generic failure line and logs the original,/// so the wording stays localized and the detail is still recoverable.fn auth_error_message(err: &nhost::NhostError) -> String { match err.error.as_deref() { Some("invalid-email") => t("auth.invalidEmail"), Some("email-already-in-use") => t("auth.emailAlreadyInUse"), Some("unverified-user") => t("auth.emailNotVerified"), Some("user-not-found") => t("auth.userNotFound"), Some("invalid-email-password") => t("auth.wrongCredentials"), Some("password-too-short") => t("auth.passwordTooShort"), Some("password-in-hibp") => t("auth.passwordCompromised"), Some("disabled-user") => t("auth.userDisabled"), // A value broke the request schema — a length or format rule. The // detail ("minimum string length is 3") names no field, so the message // has to ask the user to look rather than point. Some("schema-validation-error") => t("auth.invalidInput"), // A password-reset or verification link that has expired or been used. Some("unauthenticated-user") | Some("invalid-refresh-token") => t("auth.linkExpired"), // The request never reached the service: the venue wifi, not the // password. Say so, and let log_handled file it at the level a dropped // connection deserves instead of the error a mapping gap would be. Some("network_error") => { crate::errors::log_handled( "auth request failed", err.message.as_deref().unwrap_or("network_error"), ); t("error.offline") } _ => { log::error!("unmapped auth error: {err:?}"); t("error.somethingWentWrong") } }}
/// Whether a failed auth attempt is one the person can get themselves out of.////// A mistyped password is the system working, and at a congress there will be/// hundreds of them; shipping each one buries the record in noise. Being/// unverified, disabled or rate limited is different: the person is stuck behind/// something only somebody with access to the backend can clear, and nothing/// they do at the keyboard will help.fn auth_failure_is_stuck(err: &nhost::NhostError) -> bool { err.status == Some(429) || matches!( err.error.as_deref(), Some("unverified-user") | Some("disabled-user") )}
/// Record a failed auth attempt against the address it was for.////// THE ONE PLACE AN EMAIL BELONGS IN A LOG. Everywhere else the reader is either/// signed in (and carries `user_id`) or is nobody in particular. Here they are/// neither: they are trying to become someone, failing, and the address they/// typed is the only thing that says who they are. Without it a lockout reads as/// "an anonymous browser had a problem", which is exactly what it read as when/// two people could not get in and the only way to find them was the server's/// own logs.////// Nothing else from the form is recorded, and never the password.fn note_auth_failure(what: &str, email: &str, err: &nhost::NhostError) { let kind = err.error.as_deref().unwrap_or("unknown"); let status = err .status .map(|s| s.to_string()) .unwrap_or_else(|| "-".into()); if auth_failure_is_stuck(err) { log::warn!("{what} failed for {email}: {kind} (status {status}), they cannot clear this themselves"); } else { log::info!("{what} failed for {email}: {kind} (status {status})"); }}
/// Whether a failure is about the password rather than the address, so its/// message lands under the box it is actually about. Everything else a sign-up/// or a reset rejects is about the address.fn is_password_error(err: &nhost::NhostError) -> bool { if matches!( err.error.as_deref(), Some("password-too-short") | Some("password-in-hibp") ) { return true; } // A schema rejection sometimes names its field in the detail. That text is // the service's English and is never shown; it is only read here to decide // which box the localized message belongs under. matches!(err.error.as_deref(), Some("schema-validation-error")) && err .message .as_deref() .is_some_and(|m| m.to_ascii_lowercase().contains("password"))}
/// Clear the OTHER field's error when it holds `shared`, the message a PAIRED/// failure put on both boxes.////// Two failures here blame a pair rather than a field: a rejected sign-in, which/// cannot say which of email and password was wrong, and a mismatch, which is/// the two password boxes disagreeing. Either way editing one box answers the/// complaint, so the twin has to leave its error state too. On its own it went/// on claiming the pair was wrong while the user was busy correcting it.////// Matching on the message keeps this to the paired case: a field's own/// validation error is a different string and stays that field's to clear.fn clear_paired_error(mut other: Signal<String>, shared: &str) { // Compare first, then write: the read guard would still be alive inside an // `if *other.read() == ...` body and the write would panic on the borrow. let is_paired = *other.read() == shared; if is_paired { other.set(String::new()); }}
#[component]fn AuthForm(mode: AuthMode) -> Element { let nav = use_navigator(); let mut loading = use_signal(|| false); let mut name = use_signal(String::new); let mut email = use_signal(String::new); let mut password = use_signal(String::new); let mut password_repeat = use_signal(String::new); let mut error_name = use_signal(String::new); let mut error_email = use_signal(String::new); let mut error_password = use_signal(String::new); let mut error_password_repeat = use_signal(String::new); // Set when a sign-in fails because the account is unverified, so the login // screen can offer to re-send the verification email. let mut unverified = use_signal(|| false);
let title = match mode { AuthMode::Login => t("auth.login"), AuthMode::Register => t("auth.register"), AuthMode::ResetPassword => t("auth.resetPassword"), AuthMode::SetPassword => t("auth.setPassword"), };
let icon = match mode { AuthMode::Login => "login", AuthMode::Register => "person_add", AuthMode::ResetPassword => "mail", AuthMode::SetPassword => "lock", };
let mode_clone = mode.clone(); let on_submit = move |evt: FormEvent| { evt.prevent_default(); let mode = mode_clone.clone(); let nav = nav; spawn(async move { loading.set(true);
match mode { AuthMode::Login => { let em = email.read().clone(); let pw = password.read().clone(); if em.is_empty() { error_email.set(t("auth.missingEmail")); loading.set(false); return; } if pw.is_empty() { error_password.set(t("auth.missingPassword")); loading.set(false); return; } match nhost::sign_in(&em, &pw).await { Ok(session) => { let new_session = Session { access_token_expires_at: expires_at_from( session.access_token_expires_in, ), user: session.user.map(|u| User { id: u.id, email: u.email.unwrap_or_default(), display_name: u.display_name.unwrap_or_default(), avatar_url: u.avatar_url.unwrap_or_default(), }), access_token: Some(session.access_token), refresh_token: Some(session.refresh_token), node_id: None, }; save_session(&new_session); *SESSION.write() = new_session; // Refetch everything for the new session so no data // from a previous one lingers (React clears its cache). crate::session::bump_data_version(); back_to_where_they_were(); } Err(err) => { note_auth_failure("sign-in", &em, &err); match err.error.as_deref() { Some("unverified-user") => { error_email.set(t("auth.emailNotVerified")); unverified.set(true); } // Not about the credentials typed, so they say so on // their own rather than reddening both boxes. Some("disabled-user") | Some("network_error") | Some("parse_error") => { error_email.set(auth_error_message(&err)); } // Anything else a sign-in can fail with is a rejected // pair, and the service will not say which half was // wrong, so both boxes carry the same message. _ => { error_email.set(t("auth.wrongCredentials")); error_password.set(t("auth.wrongCredentials")); } } } } } AuthMode::Register => { let nm = name.read().clone(); let em = email.read().clone(); let pw = password.read().clone(); let pw2 = password_repeat.read().clone(); if nm.is_empty() { error_name.set(t("auth.missingName")); loading.set(false); return; } if nm.chars().count() < MIN_FIELD_LEN { error_name.set(t("auth.nameTooShort")); loading.set(false); return; } if em.is_empty() { error_email.set(t("auth.missingEmail")); loading.set(false); return; } if pw.is_empty() { error_password.set(t("auth.missingPassword")); loading.set(false); return; } if pw.chars().count() < MIN_FIELD_LEN { error_password.set(t("auth.passwordTooShort")); loading.set(false); return; } if pw2.is_empty() || pw != pw2 { // The two boxes disagree, so neither is the wrong one: // both carry it, and editing either clears both. error_password.set(t("auth.passwordMismatch")); error_password_repeat.set(t("auth.passwordMismatch")); loading.set(false); return; } match nhost::sign_up(&em, &pw, &nm).await { Ok(()) => { nav.push(Route::Unverified {}); } Err(err) => { note_auth_failure("sign-up", &em, &err); // Under the box it is about: a rejected password // is not a complaint about the address. let msg = auth_error_message(&err); if is_password_error(&err) { error_password.set(msg); } else { error_email.set(msg); } } } } AuthMode::ResetPassword => { let em = email.read().clone(); if em.is_empty() { error_email.set(t("auth.missingEmail")); loading.set(false); return; } match nhost::reset_password(&em).await { Ok(()) => { // Confirm the mail went out — do NOT jump to // set-password. That screen changes the password of // the CURRENT session, and asking for a reset does // not create one; reaching it from here showed a // form that could not work. The emailed link is what // lands there, carrying the session with it. nav.push(Route::CheckEmail {}); } // This screen is the address alone: it renders no password // box for a message to land under. Err(err) => { note_auth_failure("password-reset", &em, &err); error_email.set(auth_error_message(&err)); } } } AuthMode::SetPassword => { let pw = password.read().clone(); let pw2 = password_repeat.read().clone(); if pw.is_empty() { error_password.set(t("auth.missingPassword")); loading.set(false); return; } if pw.chars().count() < MIN_FIELD_LEN { error_password.set(t("auth.passwordTooShort")); loading.set(false); return; } if pw2.is_empty() || pw != pw2 { // The two boxes disagree, so neither is the wrong one: // both carry it, and editing either clears both. error_password.set(t("auth.passwordMismatch")); error_password_repeat.set(t("auth.passwordMismatch")); loading.set(false); return; } let token = SESSION.read().access_token.clone().unwrap_or_default(); match nhost::change_password(&token, &pw).await { Ok(()) => { back_to_where_they_were(); } Err(err) => { error_password.set(auth_error_message(&err)); } } } }
loading.set(false); }); };
let has_errors = !error_name.read().is_empty() || !error_email.read().is_empty() || !error_password.read().is_empty() || !error_password_repeat.read().is_empty();
rsx! { div { class: "auth-container", form { class: "auth-form", onsubmit: on_submit, // DESIGN: a hero icon badge (matching the home/profile heroes). div { class: "auth-hero-icon", span { class: "material-icons", "{icon}" } } h2 { class: "headline-small auth-title", "{title}" }
// Name field (register only) if mode == AuthMode::Register { div { class: if error_name.read().is_empty() { "text-field" } else { "text-field error" }, label { r#for: "auth-fullname", "{t(\"auth.fullName\")}" } input { id: "auth-fullname", r#type: "text", name: "fullname", value: "{name}", oninput: move |evt| { name.set(evt.value()); if !evt.value().is_empty() { error_name.set(String::new()); } }, } if !error_name.read().is_empty() { div { class: "helper-text", "{error_name}" } } } }
// Email field (not for set-password) if mode != AuthMode::SetPassword { div { class: if error_email.read().is_empty() { "text-field" } else { "text-field error" }, label { r#for: "auth-email", "{t(\"auth.email\")}" } input { id: "auth-email", r#type: "email", name: "email", autocomplete: "username", value: "{email}", oninput: move |evt| { email.set(evt.value()); if !evt.value().is_empty() { error_email.set(String::new()); } // A wrong sign-in marked the password too. clear_paired_error(error_password, &t("auth.wrongCredentials")); }, } if !error_email.read().is_empty() { div { class: "helper-text", "{error_email}" } } } // Offer to re-send the verification email when a sign-in was // rejected because the account is not verified yet. if mode == AuthMode::Login && *unverified.read() { button { r#type: "button", class: "btn btn-text mt-1", onclick: move |_| { let em = email.read().trim().to_lowercase(); if em.is_empty() { return; } spawn(async move { match nhost::send_verification_email(&em).await { Ok(()) => crate::snackbar::show_snackbar(&t("auth.verificationResent")), // Rate limited, which is a sentence this // button can actually say. A reader who // is told "something went wrong" presses // it again -- one pressed four times, // and every press was another 429 -- so // the shrug turned a wait into a wall. Err(e) if e.status == Some(429) => { note_auth_failure("verification-resend", &em, &e); crate::snackbar::show_snackbar(&t("auth.verificationTooSoon")) } Err(e) => { note_auth_failure("verification-resend", &em, &e); // The call site is the label: a generic toast tells the reader // nothing, so the log has to say at least where it came from. crate::errors::log_handled(concat!(file!(), ":", line!()), e); crate::snackbar::show_snackbar(&t("error.somethingWentWrong")) } } }); }, "{t(\"auth.resendVerification\")}" } } }
// Password field (not for reset-password) if mode != AuthMode::ResetPassword { div { class: if error_password.read().is_empty() { "text-field" } else { "text-field error" }, label { r#for: "auth-password", if mode == AuthMode::SetPassword { "{t(\"auth.newPassword\")}" } else { "{t(\"auth.password\")}" } } div { class: "pw-field", input { id: "auth-password", class: "pw-input", r#type: "password", name: "password", autocomplete: "current-password", value: "{password}", oninput: move |evt| { password.set(evt.value()); error_password.set(String::new()); // ...and the email, unless its error is its own // (an unverified account keeps its message and // the resend button that goes with it). clear_paired_error(error_email, &t("auth.wrongCredentials")); // ...and the repeat box, which a mismatch marked // alongside this one. clear_paired_error( error_password_repeat, &t("auth.passwordMismatch"), ); }, } super::widgets::PasswordDots { len: password.read().chars().count() } } if !error_password.read().is_empty() { div { class: "helper-text", "{error_password}" } } } }
// Repeat password (register and set-password) if mode == AuthMode::Register || mode == AuthMode::SetPassword { div { class: if error_password_repeat.read().is_empty() { "text-field" } else { "text-field error" }, label { r#for: "auth-password-repeat", "{t(\"auth.repeatPassword\")}" } div { class: "pw-field", input { id: "auth-password-repeat", class: "pw-input", r#type: "password", name: "password-repeat", value: "{password_repeat}", oninput: move |evt| { password_repeat.set(evt.value()); error_password_repeat.set(String::new()); // A mismatch marked the first box as well. clear_paired_error(error_password, &t("auth.passwordMismatch")); }, } super::widgets::PasswordDots { len: password_repeat.read().chars().count(), } } if !error_password_repeat.read().is_empty() { div { class: "helper-text", "{error_password_repeat}" } } } }
// Submit button div { class: "btn-busy", button { class: "btn btn-primary btn-full", r#type: "submit", disabled: *loading.read() || has_errors, "{title}" } if *loading.read() { div { class: "btn-busy-spinner", div { class: "spinner spinner-sm" } } } }
// Extra buttons for login page if mode == AuthMode::Login { button { class: "btn btn-secondary btn-full", r#type: "button", onclick: move |_| { nav.push(Route::Register {}); }, "{t(\"auth.register\")}" } button { class: "btn btn-secondary btn-full", r#type: "button", onclick: move |_| { nav.push(Route::ResetPassword {}); }, "{t(\"auth.resetPassword\")}" } } } } }}
/// Put the reader back where they were before they signed in.////// The front page is where a reader who arrived at the door belongs; anyone/// else was reading something, and sending them to the front page loses it --/// they came to sign in so they could see THAT page. Where the app has no/// record (a fresh tab opened on the login screen, or a link straight to it),/// the front page is still the answer.fn back_to_where_they_were() { let nav = navigator(); match crate::nav_memory::way_back().and_then(|url| url.parse::<Route>().ok()) { Some(back) => nav.push(back), None => nav.push(Route::Home { app: None }), };}
#[component]pub fn Login() -> Element { rsx! { AuthForm { mode: AuthMode::Login } }}
#[component]pub fn Register() -> Element { rsx! { AuthForm { mode: AuthMode::Register } }}
#[component]pub fn ResetPassword() -> Element { rsx! { AuthForm { mode: AuthMode::ResetPassword } }}
#[component]pub fn SetPassword() -> Element { rsx! { AuthForm { mode: AuthMode::SetPassword } }}
/// Shown after asking for a password-reset link: the mail is out, and the next/// step is in the inbox. Mirrors [`Unverified`], the same shape of "we sent you/// something, go and look" screen.#[component]pub fn CheckEmail() -> Element { rsx! { div { class: "auth-container", div { class: "auth-form", div { class: "auth-hero-icon", span { class: "material-icons", "mark_email_read" } } h2 { class: "headline-small auth-title", "{t(\"auth.checkEmail\")}" } p { class: "body-large", "{t(\"auth.passwordResetSent\")}" } p { class: "body-large", "{t(\"auth.useToResetPassword\")}" } p { class: "body-medium", "{t(\"auth.checkSpam\")}" } } } }}
#[component]pub fn Unverified() -> Element { rsx! { div { class: "auth-container", div { class: "auth-form", div { class: "auth-hero-icon", span { class: "material-icons", "mail" } } h2 { class: "headline-small auth-title", "{t(\"auth.verifyEmail\")}" } p { class: "body-large", "{t(\"auth.verificationEmailSent\")}" } p { class: "body-large", "{t(\"auth.useToActivate\")}" } p { class: "body-medium", "{t(\"auth.checkSpam\")}" } } } }}
#[cfg(test)]mod tests { use super::*;
fn err(code: Option<&str>, status: Option<u16>) -> nhost::NhostError { nhost::NhostError { status, error: code.map(str::to_string), message: None, } }
/// Loud only for the failures the person cannot walk out of. /// /// These are the ones worth a record: an unverified account and a rate /// limited resend are what locked two people out on the eve of a congress, /// and both needed somebody with database access to clear. #[test] fn a_reader_who_is_stuck_is_the_one_worth_recording() { assert!(auth_failure_is_stuck(&err(Some("unverified-user"), None))); assert!(auth_failure_is_stuck(&err(Some("disabled-user"), None))); // The resend button, pressed again because the app said nothing useful. assert!(auth_failure_is_stuck(&err(None, Some(429)))); }
/// And quiet for the ones that are simply someone typing. /// /// A congress means hundreds of mistyped passwords. Shipping each one buries /// the handful that matter, which is the same reason a lapsed session and a /// dropped connection are kept off the wire. #[test] fn a_mistyped_password_is_not_an_incident() { assert!(!auth_failure_is_stuck(&err( Some("invalid-email-password"), Some(401) ))); assert!(!auth_failure_is_stuck(&err(Some("user-not-found"), None))); assert!(!auth_failure_is_stuck(&err(Some("invalid-email"), None))); assert!(!auth_failure_is_stuck(&err(None, None))); }}