diff --git a/Cargo.toml b/Cargo.toml index 7964bd5..d97419a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,9 @@ futures-util = "0.3.29" futures = "0.3.29" rand = "0.8.5" reqwest = "0.11.24" +validator = { version = "0.17.0", features = ["derive"] } const_format = "0.2.32" +zxcvbn = "2.2.2" [build] target = "x86_64-unknown-linux-gnu" diff --git a/README.md b/README.md index 320d6be..954dfd9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The goal of this project is to propose a new open-source implementation of the f ## Back-end This project is based on the [Matrix.org](https://matrix.org/) building blocks (back-end and front-end SDK) to avoid to -reinvent the wheel. This solution provides: +reinvent the wheel. This solution provides: - [Open-source protocol](https://spec.matrix.org/v1.9/). - Features expected for a messaging solution in 2024 (multi-devices management, emojis, integrations, redaction, diff --git a/src/components/login.rs b/src/components/login.rs index 3ef39f9..dd34424 100644 --- a/src/components/login.rs +++ b/src/components/login.rs @@ -1,14 +1,20 @@ -use const_format::{concatcp, formatcp}; +use std::borrow::Cow; +use std::collections::hash_map::Values; +use std::collections::HashMap; + +use const_format::formatcp; use dioxus::prelude::*; use fermi::*; use rand::distributions::{Alphanumeric, DistString}; use tracing::{debug, error, warn}; +use validator::{Validate, ValidateArgs, ValidateEmail, ValidationError, ValidationErrors}; +use zxcvbn::zxcvbn; use crate::base::{Session, SESSION}; use super::button::{LoginButton, RegisterButton}; use super::spinner::Spinner; -use super::text_field::TextField; +use super::text_input::{TextInput, TextInputState}; use super::wallpaper::Wallpaper; @@ -70,30 +76,253 @@ async fn generate_random_avatar(url: &String) -> Option { res } -struct LoginData { - homeserver_url: Option, - email: Option, - password: Option, +const REQUIRED_ERROR_NAME: &'static str = "required"; +const REQUIRED_ERROR_HELPER_TEXT: &'static str = "This field must be completed"; + +const INVALID_URL_ERROR_NAME: &'static str = "url"; +const INVALID_URL_ERROR_HELPER_TEXT: &'static str = "This field must be a valid URL"; + +const INVALID_EMAIL_ERROR_NAME: &'static str = "email"; +const INVALID_EMAIL_ERROR_HELPER_TEXT: &'static str = "This field must be a valid email"; + +const TOO_WEAK_PASSWORD_ERROR_NAME: &'static str = "too_weak_password"; +const TOO_WEAK_PASSWORD_ERROR_HELPER_TEXT: &'static str = "The password is too weak"; + +const FIELDS_MISMATCH_ERROR_NAME: &'static str = "mismatch"; + +const HOMESERVER_FIELD_NAME: &'static str = "homeserver_url"; +const ID_FIELD_NAME: &'static str = "id"; +const PASSWORD_FIELD_NAME: &'static str = "password"; +const CONFIRM_PASSWORD_FIELD_NAME: &'static str = "confirm password"; + +const LOGIN_ID_PLACEHOLDER: &'static str = "Username"; +const REGISTER_ID_PLACEHOLDER: &'static str = "Email"; + +#[derive(PartialEq)] +enum Process { + LOGIN, + REGISTRATION, } -impl LoginData { +fn validate_data(data: &Data, process: &Process) -> Result<(), ValidationError> { + match process { + Process::REGISTRATION => { + let mut is_confirm_password_empty = true; + if let Some(confirm_password) = &data.confirm_password { + if confirm_password.len() > 0 { + is_confirm_password_empty = false; + } + } + if is_confirm_password_empty { + let mut err = ValidationError::new(&REQUIRED_ERROR_NAME); + err.add_param(Cow::from("field_name"), &CONFIRM_PASSWORD_FIELD_NAME); + return Err(err); + } + + if data.password != data.confirm_password { + let mut err = ValidationError::new(FIELDS_MISMATCH_ERROR_NAME); + err.add_param(Cow::from("field_name_1"), &PASSWORD_FIELD_NAME); + err.add_param(Cow::from("field_name_2"), &CONFIRM_PASSWORD_FIELD_NAME); + return Err(err); + } + } + Process::LOGIN => {} + } + Ok(()) +} + +fn validate_id(id: &Option, process: &Process) -> Result<(), ValidationError> { + if *process == Process::REGISTRATION { + if !id.validate_email() { + let err = ValidationError::new(&INVALID_EMAIL_ERROR_NAME); + return Err(err); + } + } + + Ok(()) +} + +fn validate_password(password: &Option, process: &Process) -> Result<(), ValidationError> { + if *process == Process::REGISTRATION { + if let Some(password) = password { + let estimate = zxcvbn(password, &[]).unwrap(); + + let score = estimate.score(); + + // TODO: Give the limit using the configuration + if score <= 2 { + let mut err = ValidationError::new(TOO_WEAK_PASSWORD_ERROR_NAME); + err.add_param(Cow::from("score"), &score); + + if let Some(feedback) = estimate.feedback() { + let suggestions = feedback + .suggestions() + .iter() + .map(|s| s.to_string()) + .collect::>(); + err.add_param(Cow::from("suggestions"), &suggestions); + } + + return Err(err); + } + } + } + + Ok(()) +} + +#[derive(Debug, Validate)] +#[validate(context = "Process")] +#[validate(schema( + function = validate_data, + use_context, + skip_on_field_errors = false +))] +struct Data { + #[validate(required, url)] + homeserver_url: Option, + #[validate(required, custom(function = validate_id, use_context))] + id: Option, + #[validate(required, custom(function = validate_password, use_context))] + password: Option, + confirm_password: Option, +} + +impl Data { fn new() -> Self { Self { homeserver_url: None, - email: None, + id: None, password: None, + confirm_password: None, } } } -async fn on_login(session_ref: &UseAtomRef, login_ref: &UseRef) { - let login = login_ref.read(); +fn on_login( + session_ref: &UseAtomRef, + data_ref: &UseRef, +) -> Result<(), ValidationErrors> { + let login = data_ref.read(); - session_ref.write().update( - login.homeserver_url.clone(), - login.email.clone(), - login.password.clone(), - ); + match login.validate_with_args(&Process::LOGIN) { + Ok(_) => { + session_ref.write().update( + login.homeserver_url.clone(), + login.id.clone(), + login.password.clone(), + ); + Ok(()) + } + Err(err) => Err(err), + } +} + +fn on_register( + _session_ref: &UseAtomRef, + data_ref: &UseRef, +) -> Result<(), ValidationErrors> { + let login = data_ref.read(); + + match login.validate_with_args(&Process::REGISTRATION) { + Ok(_) => { + error!("TODO: Manage registration process"); + Ok(()) + } + Err(err) => Err(err), + } +} + +fn on_validation_errors( + field_errors: &HashMap<&str, &Vec>, + by_field_name_states: &HashMap<&'static str, &UseRef>, +) { + for (field_name, errors) in field_errors { + if let Some(state) = by_field_name_states.get(field_name) { + for error in *errors { + let code = error.code.to_string(); + match code.as_str() { + REQUIRED_ERROR_NAME => { + state + .write() + .invalidate(REQUIRED_ERROR_HELPER_TEXT.to_string()); + } + INVALID_URL_ERROR_NAME => { + state + .write() + .invalidate(INVALID_URL_ERROR_HELPER_TEXT.to_string()); + } + INVALID_EMAIL_ERROR_NAME => { + state + .write() + .invalidate(INVALID_EMAIL_ERROR_HELPER_TEXT.to_string()); + } + TOO_WEAK_PASSWORD_ERROR_NAME => { + state + .write() + .invalidate(TOO_WEAK_PASSWORD_ERROR_HELPER_TEXT.to_string()); + } + other => todo!("{:?}", other), + } + } + } else if *field_name == "__all__" { + for error in *errors { + let code = error.code.to_string(); + match code.as_str() { + FIELDS_MISMATCH_ERROR_NAME => { + let values = error.params.values(); + let field_names = values + .into_iter() + .filter_map(|v| v.as_str()) + .collect::>(); + for field_name in field_names.iter() { + if let Some(state) = by_field_name_states.get(*field_name) { + let other_field_names = field_names + .iter() + .filter(|f| **f != *field_name) + .map(|f| *f) + .collect::>(); + let other_field_names_len = other_field_names.len(); + let formatted = format!( + "This field must match with {}{}{} field{}", + other_field_names[0..other_field_names_len - 1].join(", "), + if other_field_names_len > 1 { + " and " + } else { + "" + }, + other_field_names[other_field_names_len - 1], + if other_field_names_len > 1 { "s" } else { "" }, + ); + + if state.read().is_valid { + state.write().invalidate(formatted); + } + } + } + } + REQUIRED_ERROR_NAME => { + if let Some(field_value) = error.params.get("field_name") { + if let Some(field_name) = field_value.as_str() { + if let Some(state) = by_field_name_states.get(field_name) { + state + .write() + .invalidate(REQUIRED_ERROR_HELPER_TEXT.to_string()); + } + } + } + } + other => todo!("{:?}", other), + } + } + } else { + error!("No validation state found for \"{field_name}\" field name"); + } + } +} + +fn reset_states(states: Values<&'static str, &UseRef>) { + let _ = states.for_each(|s| s.write().reset()); } #[derive(Props)] @@ -106,14 +335,29 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> { let session = use_atom_ref(cx, &SESSION); - let login = use_ref(cx, LoginData::new); - let login_ref = login.read(); - let homeserver_url = login_ref.homeserver_url.as_deref().unwrap_or(""); - let email = login_ref.email.as_deref().unwrap_or(""); - let password = login_ref.password.as_deref().unwrap_or(""); + let data_ref = use_ref(cx, Data::new); + + let current_process = use_state(cx, || Process::LOGIN); + + let data = data_ref.read(); + let homeserver_url = data.homeserver_url.as_deref().unwrap_or(""); + let homeserver_url_state = use_ref(cx, TextInputState::new); + let id = data.id.as_deref().unwrap_or(""); + let id_state = use_ref(cx, TextInputState::new); + let password = data.password.as_deref().unwrap_or(""); + let password_state = use_ref(cx, TextInputState::new); + let confirm_password = data.confirm_password.as_deref().unwrap_or(""); + let confirm_password_state = use_ref(cx, TextInputState::new); + + let by_field_name_states: HashMap<&'static str, &UseRef> = HashMap::from([ + (HOMESERVER_FIELD_NAME, homeserver_url_state), + (ID_FIELD_NAME, id_state), + (PASSWORD_FIELD_NAME, password_state), + (CONFIRM_PASSWORD_FIELD_NAME, confirm_password_state), + ]); - // TODO: Enable the spinner for registration and login steps. let spinner_animated = use_state(cx, || false); + let id_placeholder = use_state(cx, || LOGIN_ID_PLACEHOLDER); let url = cx .props @@ -151,18 +395,73 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> { spinner_animated.set(false); } - let on_login = move |_| { - cx.spawn({ + let on_login = { + to_owned![by_field_name_states]; - to_owned![login, session, spinner_animated]; + move |_| { + reset_states(by_field_name_states.values()); - async move { - spinner_animated.set(true); - on_login(&session, &login).await; + if **current_process == Process::REGISTRATION { + current_process.set(Process::LOGIN); + data_ref.write().id = None; + return; } - }) + + spinner_animated.set(true); + + if let Err(errors) = on_login(&session, &data_ref) { + let field_errors = errors.field_errors(); + on_validation_errors(&field_errors, &by_field_name_states); + } + + spinner_animated.set(false); + } }; + let on_register = { + to_owned![by_field_name_states]; + + move |_| { + reset_states(by_field_name_states.values()); + + if **current_process == Process::LOGIN { + current_process.set(Process::REGISTRATION); + data_ref.write().id = None; + return; + } + + spinner_animated.set(true); + + if let Err(errors) = on_register(&session, &data_ref) { + let field_errors = errors.field_errors(); + on_validation_errors(&field_errors, &by_field_name_states); + } + + spinner_animated.set(false); + } + }; + + let mut form_classes: [&str; 2] = [ClassName::FORM, ""]; + let mut confirm_password_classes: [&str; 2] = [ClassName::CONFIRM_PASSWORD, ""]; + match **current_process { + Process::REGISTRATION => { + form_classes[1] = ClassName::REGISTER; + confirm_password_classes[1] = ClassName::SHOW; + + if **id_placeholder != REGISTER_ID_PLACEHOLDER { + id_placeholder.set(REGISTER_ID_PLACEHOLDER); + } + } + Process::LOGIN => { + if **id_placeholder != LOGIN_ID_PLACEHOLDER { + id_placeholder.set(LOGIN_ID_PLACEHOLDER); + } + } + } + + let form_classes_str = form_classes.join(" "); + let confirm_password_classes_str = confirm_password_classes.join(" "); + cx.render(rsx! { style { STYLE_SHEET }, @@ -172,7 +471,7 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> { class: ClassName::ROOT, div { - class: ClassName::FORM, + class: "{form_classes_str}", div { class: ClassName::PHOTO, @@ -186,35 +485,50 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> { div { class: ClassName::HOMESERVER, - TextField { + TextInput { id: "hs_url", r#type: "text", placeholder: "Homeserver URL", value: "{homeserver_url}", - oninput: move |evt: FormEvent| login.write().homeserver_url = Some(evt.value.clone()), + state: homeserver_url_state, + oninput: move |evt: FormEvent| data_ref.write().homeserver_url = if evt.value.len() > 0 { + Some(evt.value.clone()) } else { None }, }, }, div { - class: ClassName::EMAIL, - TextField { - id: "email", - r#type: "email", - placeholder: "Email", - value: "{email}", - is_value_valid: false, - oninput: move |evt: FormEvent| login.write().email = Some(evt.value.clone()), + class: ClassName::ID, + TextInput { + r#type: "text", + placeholder: "{id_placeholder}", + value: "{id}", + state: id_state, + oninput: move |evt: FormEvent| data_ref.write().id = if evt.value.len() > 0 { + Some(evt.value.clone()) } else { None }, }, }, div { class: ClassName::PASSWORD, - TextField { - id: "password", + TextInput { r#type: "password", placeholder: "Password", value: "{password}", - oninput: move |evt: FormEvent| login.write().password = Some(evt.value.clone()), + state: password_state, + oninput: move |evt: FormEvent| data_ref.write().password = if evt.value.len() > 0 { + Some(evt.value.clone()) } else { None }, + }, + }, + + div { + class: "{confirm_password_classes_str}", + TextInput { + r#type: "password", + placeholder: "Confirm Password", + value: "{confirm_password}", + state: confirm_password_state, + oninput: move |evt: FormEvent| data_ref.write().confirm_password = if evt.value.len() > 0 { + Some(evt.value.clone()) } else { None }, }, }, @@ -228,15 +542,13 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> { div { class: ClassName::REGISTER, RegisterButton { - id: "register", - // TODO: Handle the registration process + onclick: on_register, }, }, div { class: ClassName::LOGIN, LoginButton { - id: "login", focus: true, onclick: on_login, }, diff --git a/src/components/login.scss b/src/components/login.scss index 0bc5243..639ce05 100644 --- a/src/components/login.scss +++ b/src/components/login.scss @@ -45,16 +45,18 @@ grid-template-columns: $padding-col $edit-padding $photo-padding $button-overlap $profile-img-ext-width $center-width $profile-img-ext-width $button-overlap $photo-padding $edit-padding $padding-col; - grid-template-rows: $padding-row $profile-img-height auto 5% 5% 5% 5% 5% 8.5% $spinner-height 8.5% $button-height $padding-row; + grid-template-rows: $padding-row $profile-img-height auto 5% 5% 5% 5% 5% 0% 0% 8.5% $spinner-height 8.5% $button-height $padding-row; grid-template-areas: ". . . . . . . . . . ." ". . . photo photo photo photo photo . . ." ". . . . . . . . . . ." ". . homeserver homeserver homeserver homeserver homeserver homeserver homeserver . ." ". . . . . . . . . . ." - ". . username username username username username username username . ." + ". . id id id id id id id . ." ". . . . . . . . . . ." - ". . status status status status status status status . ." + ". . password password password password password password password . ." + ". . . . . . . . . . ." + ". . confirm confirm confirm confirm confirm confirm confirm . ." ". . . . . . . . . . ." ". . . . spinner spinner spinner . . . ." ". . . . . . . . . . ." @@ -62,6 +64,12 @@ ". . . . . . . . . . ." ; + transition: 300ms; + + &.register { + grid-template-rows: $padding-row $profile-img-height auto 5% 5% 5% 5% 5% 5% 5% 5% $spinner-height 5% $button-height $padding-row; + } + .photo { grid-area: photo; @@ -84,12 +92,21 @@ grid-area: homeserver; } - .email { - grid-area: username; + .id { + grid-area: id; } .password { - grid-area: status; + grid-area: password; + } + + .confirm-password { + grid-area: confirm; + display: none; + + &.show { + display: initial; + } } .spinner {