use std::{borrow::Cow, cell::RefCell, collections::HashMap, rc::Rc}; use const_format::formatcp; use dioxus::prelude::*; use tracing::{debug, error, warn}; use validator::{Validate, ValidateArgs, ValidateEmail, ValidationError, ValidationErrors}; use zxcvbn::zxcvbn; use crate::{ domain::model::session::Session, infrastructure::services::random_svg_generators::{generate_random_svg_shape, ShapeConfig}, ui::SESSION, }; use super::{ button::{LoginButton, RegisterButton}, modal::{Modal, Severity}, spinner::Spinner, text_input::{PasswordInputState, PasswordTextInput, TextInput, TextInputState}, }; include!(concat!(env!("OUT_DIR"), "/style_tokens.rs")); use style::{ COLOR_PRIMARY_100, COLOR_PRIMARY_110, COLOR_PRIMARY_120, COLOR_PRIMARY_140, COLOR_PRIMARY_150, COLOR_PRIMARY_80, COLOR_PRIMARY_90, COLOR_SECONDARY_100, COLOR_SECONDARY_110, COLOR_SECONDARY_120, COLOR_SECONDARY_140, COLOR_SECONDARY_150, COLOR_SECONDARY_80, COLOR_SECONDARY_90, COLOR_TERNARY_100, COLOR_TERNARY_110, COLOR_TERNARY_120, COLOR_TERNARY_140, COLOR_TERNARY_150, COLOR_TERNARY_80, COLOR_TERNARY_90, }; turf::style_sheet!("src/ui/components/login.scss"); const BACKGROUND_COLORS_STR: &str = formatcp!( "{COLOR_PRIMARY_150},{COLOR_PRIMARY_140},\ {COLOR_SECONDARY_150},{COLOR_SECONDARY_140},\ {COLOR_TERNARY_150},{COLOR_TERNARY_140}" ); const SHAPE_1_COLORS_STR: &str = formatcp!( "{COLOR_PRIMARY_120},{COLOR_PRIMARY_110},{COLOR_PRIMARY_100},{COLOR_PRIMARY_90},{COLOR_PRIMARY_80}" ); const SHAPE_2_COLORS_STR: &str = formatcp!( "{COLOR_SECONDARY_120},{COLOR_SECONDARY_110},{COLOR_SECONDARY_100},{COLOR_SECONDARY_90},{COLOR_SECONDARY_80}"); const SHAPE_3_COLORS_STR: &str = formatcp!( "{COLOR_TERNARY_120},{COLOR_TERNARY_110},{COLOR_TERNARY_100},{COLOR_TERNARY_90},{COLOR_TERNARY_80}"); const REQUIRED_ERROR_NAME: &str = "required"; const REQUIRED_ERROR_HELPER_TEXT: &str = "This field must be completed"; const INVALID_URL_ERROR_NAME: &str = "url"; const INVALID_URL_ERROR_HELPER_TEXT: &str = "This field must be a valid URL"; const INVALID_EMAIL_ERROR_NAME: &str = "email"; const INVALID_EMAIL_ERROR_HELPER_TEXT: &str = "This field must be a valid email"; const TOO_WEAK_PASSWORD_ERROR_NAME: &str = "too_weak_password"; const TOO_WEAK_PASSWORD_ERROR_HELPER_TEXT: &str = "The password is too weak"; const FIELDS_MISMATCH_ERROR_NAME: &str = "mismatch"; const HOMESERVER_FIELD_NAME: &str = "homeserver_url"; const ID_FIELD_NAME: &str = "id"; const PASSWORD_FIELD_NAME: &str = "password"; const CONFIRM_PASSWORD_FIELD_NAME: &str = "confirm password"; const LOGIN_ID_PLACEHOLDER: &str = "Username"; const REGISTER_ID_PLACEHOLDER: &str = "Email"; #[derive(PartialEq)] enum Process { Login, Registration, } trait OnValidationError { fn on_validation_error(&mut self, error: &ValidationError) { let code = error.code.to_string(); let msg = match code.as_str() { REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT), _ => None, }; if let Some(msg) = msg { self.invalidate(msg.to_string()); } } fn reset(&mut self); fn invalidate(&mut self, helper_text: String); fn box_clone(&self) -> Box; } impl Clone for Box { fn clone(&self) -> Self { self.box_clone() } } #[derive(Clone)] struct UrlInputHandler { state: Signal, } impl UrlInputHandler { pub fn new(state: Signal) -> Self { Self { state } } } impl OnValidationError for UrlInputHandler { fn on_validation_error(&mut self, error: &ValidationError) { let code = error.code.to_string(); let msg = match code.as_str() { REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT), INVALID_URL_ERROR_NAME => Some(INVALID_URL_ERROR_HELPER_TEXT), _ => None, }; if let Some(msg) = msg { self.invalidate(msg.to_string()); } } fn reset(&mut self) { self.state.write().reset(); } fn invalidate(&mut self, helper_text: String) { self.state.write().invalidate(helper_text); } fn box_clone(&self) -> Box { Box::new(self.clone()) } } #[derive(Clone)] struct EmailInputHandler { state: Signal, } impl EmailInputHandler { pub fn new(state: Signal) -> Self { Self { state } } } impl OnValidationError for EmailInputHandler { fn on_validation_error(&mut self, error: &ValidationError) { let code = error.code.to_string(); let msg = match code.as_str() { REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT), INVALID_EMAIL_ERROR_NAME => Some(INVALID_EMAIL_ERROR_HELPER_TEXT), _ => None, }; if let Some(msg) = msg { self.invalidate(msg.to_string()); } } fn reset(&mut self) { self.state.write().reset(); } fn invalidate(&mut self, helper_text: String) { self.state.write().invalidate(helper_text); } fn box_clone(&self) -> Box { Box::new(self.clone()) } } #[derive(Clone)] struct PasswordInputHandler { state: Signal, } impl PasswordInputHandler { pub fn new(state: Signal) -> Self { Self { state } } } impl OnValidationError for PasswordInputHandler { fn on_validation_error(&mut self, error: &ValidationError) { let code = error.code.to_string(); let msg = match code.as_str() { REQUIRED_ERROR_NAME => Some(REQUIRED_ERROR_HELPER_TEXT), INVALID_EMAIL_ERROR_NAME => Some(INVALID_EMAIL_ERROR_HELPER_TEXT), TOO_WEAK_PASSWORD_ERROR_NAME => { let mut score = 0.0; if let Some(guesses_log10) = error.params.get("guesses_log10") { if let Some(guesses_log10) = guesses_log10.as_f64() { score = guesses_log10; } } self.state.write().score = score; Some(TOO_WEAK_PASSWORD_ERROR_HELPER_TEXT) } _ => None, }; if let Some(msg) = msg { self.invalidate(msg.to_string()); } } fn reset(&mut self) { self.state.write().reset(); } fn invalidate(&mut self, helper_text: String) { self.state.write().invalidate(helper_text); } fn box_clone(&self) -> Box { Box::new(self.clone()) } } fn on_validation_errors( field_errors: &HashMap<&str, &Vec>, handlers: &InputHandlers, ) { for (field_name, errors) in field_errors { if let Some(handler) = handlers.get(field_name) { errors .iter() .for_each(|e| handler.borrow_mut().on_validation_error(e)); } 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(handler) = handlers.get(field_name) { let other_field_names = field_names .iter() .filter(|f| **f != *field_name) .copied() .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 { "" }, ); handler.borrow_mut().invalidate(formatted); } } } REQUIRED_ERROR_NAME => { let Some(field_value) = error.params.get("field_name") else { continue; }; let Some(field_name) = field_value.as_str() else { continue; }; if let Some(handler) = handlers.get(field_name) { handler.borrow_mut().on_validation_error(error); } } other => todo!("{:?}", other), } } } else { error!("No validation state found for \"{field_name}\" field name"); } } } #[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, id: None, password: None, confirm_password: None, } } } 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.is_empty() { 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 && !id.validate_email() { let err = ValidationError::new(INVALID_EMAIL_ERROR_NAME); return Err(err); } Ok(()) } struct PasswordValidationResult { score: u8, rating: f64, // 0 <= rating <= 1 suggestions: Vec, } impl PasswordValidationResult { pub fn new() -> Self { PasswordValidationResult { score: 0, rating: 0.0, suggestions: Vec::::new(), } } } fn compute_password_score( password: &str, with_suggestions: Option, ) -> Option { let Ok(estimate) = zxcvbn(password, &[]) else { return None; }; let mut result = PasswordValidationResult::new(); result.score = estimate.score(); let mut rating = estimate.guesses_log10() * 4.5 / 100.0; if rating > 1.0 { rating = 1.0; }; result.rating = rating; let with_suggestions = with_suggestions.unwrap_or(false); if with_suggestions { if let Some(feedback) = estimate.feedback() { for suggestion in feedback.suggestions() { result.suggestions.push(suggestion.to_string()); } } } Some(result) } fn validate_password(password: &Option, process: &Process) -> Result<(), ValidationError> { if *process == Process::Registration { if let Some(password) = password { if let Some(result) = compute_password_score(password, Some(true)) { // TODO: To configuration? if result.score <= 2 { let mut err = ValidationError::new(TOO_WEAK_PASSWORD_ERROR_NAME); err.add_param(Cow::from("score"), &result.score); err.add_param(Cow::from("rating"), &result.rating); err.add_param(Cow::from("suggestions"), &result.suggestions); return Err(err); } } else { error!("Unable to compute the password score"); } } else { warn!("Password parameter is None"); } } Ok(()) } fn on_login(session: &GlobalSignal, data: Signal) -> Result<(), ValidationErrors> { let data = data.read(); match data.validate_with_args(&Process::Login) { Ok(_) => { session.write().update( data.homeserver_url.clone(), data.id.clone(), data.password.clone(), ); Ok(()) } Err(err) => Err(err), } } fn on_register( _session: &GlobalSignal, data: Signal, ) -> Result<(), ValidationErrors> { let data = data.read(); match data.validate_with_args(&Process::Registration) { Ok(_) => { error!("TODO: Manage registration process"); Ok(()) } Err(err) => Err(err), } } #[derive(Clone)] struct InputHandlers<'a> { handlers: HashMap<&'a str, Rc>>, } impl<'a> InputHandlers<'a> { fn new() -> Self { Self { handlers: HashMap::new(), } } fn insert(&mut self, name: &'a str, handler: T) { self.handlers.insert(name, Rc::new(RefCell::new(handler))); } fn get(&self, name: &'a str) -> Option<&Rc>> { if let Some(handler) = self.handlers.get(name) { return Some(handler); } None } fn reset_handlers(&self) { self.handlers.values().for_each(|h| h.borrow_mut().reset()); } } macro_rules! on_input { ($data_ref:ident, $data_field:ident) => { move |evt: FormEvent| { let value = evt.value(); $data_ref.write().$data_field = if !value.is_empty() { Some(value) } else { None } } }; } macro_rules! refresh_password_state { ($data:ident, $data_field:ident, $state:ident) => { let mut rating = 0.0; if let Some(password) = &$data.peek().$data_field { if let Some(result) = compute_password_score(password, None) { rating = result.rating; } } $state.write().score = rating; }; } fn generate_modal( config: &PasswordSuggestionsModalConfig, on_confirm: impl FnMut(Event) + 'static, ) -> Element { let suggestions = config.suggestions.get(PASSWORD_FIELD_NAME); let mut rendered_suggestions = Vec::::new(); if let Some(suggestions) = suggestions { if suggestions.len() == 1 { rendered_suggestions.push(rsx!({ suggestions[0].as_str() })); } else { suggestions .iter() .for_each(|s| rendered_suggestions.push(rsx!(li { {s.as_str()} }))); } } rsx! { Modal { severity: config.severity, title: config.title.as_ref(), on_confirm: on_confirm, div { {rendered_suggestions.iter()} } } } } type Suggestions<'a> = HashMap<&'a str, Vec>; #[derive(Clone)] struct PasswordSuggestionsModalConfig<'a> { severity: Severity, title: String, suggestions: Suggestions<'a>, } impl<'a> PasswordSuggestionsModalConfig<'a> { fn new(severity: Option, title: String) -> Self { Self { severity: severity.unwrap_or(Severity::Critical), title, suggestions: HashMap::new(), } } } pub fn Login() -> Element { debug!("Login rendering"); let mut data = use_signal(Data::new); let mut current_process = use_signal(|| Process::Login); let data_lock = data.read(); let homeserver_url = data_lock.homeserver_url.as_deref().unwrap_or(""); let homeserver_url_state = use_signal(TextInputState::new); let id = data_lock.id.as_deref().unwrap_or(""); let id_state = use_signal(TextInputState::new); let password = data_lock.password.as_deref().unwrap_or(""); let mut password_state = use_signal(PasswordInputState::new); let confirm_password = data_lock.confirm_password.as_deref().unwrap_or(""); let mut confirm_password_state = use_signal(PasswordInputState::new); let mut handlers = InputHandlers::new(); handlers.insert( HOMESERVER_FIELD_NAME, UrlInputHandler::new(homeserver_url_state), ); handlers.insert(ID_FIELD_NAME, EmailInputHandler::new(id_state)); handlers.insert( PASSWORD_FIELD_NAME, PasswordInputHandler::new(password_state), ); handlers.insert( CONFIRM_PASSWORD_FIELD_NAME, PasswordInputHandler::new(confirm_password_state), ); let mut spinner_animated = use_signal(|| false); let mut id_placeholder = use_signal(|| LOGIN_ID_PLACEHOLDER); let mut random_avatar_future = use_resource(move || async move { let shape_config = ShapeConfig::new( BACKGROUND_COLORS_STR, SHAPE_1_COLORS_STR, SHAPE_2_COLORS_STR, SHAPE_3_COLORS_STR, ); generate_random_svg_shape(Some(&shape_config)).await }); let avatar = (*random_avatar_future.read_unchecked()) .as_ref() .map(|svg| { rsx! { div { class: ClassName::LOGIN_AVATAR_CONTENT, dangerous_inner_html: svg.as_str(), } } }); if *spinner_animated.read() && SESSION.read().is_logged { spinner_animated.set(false); } refresh_password_state!(data, password, password_state); refresh_password_state!(data, confirm_password, confirm_password_state); let mut modal_configs = use_signal(Vec::::new); let mut modal_config = use_signal(|| None::); if !modal_configs.read().is_empty() && modal_config.read().is_none() { modal_config.set(modal_configs.write().pop()); } let on_clicked_login = { to_owned![handlers]; move |_| { handlers.reset_handlers(); if *current_process.read() == Process::Registration { current_process.set(Process::Login); data.write().id = None; return; } spinner_animated.set(true); if let Err(errors) = on_login(&SESSION, data) { let field_errors = errors.field_errors(); on_validation_errors(&field_errors, &handlers); } } }; let on_clicked_register = { to_owned![handlers, modal_configs]; move |_| { if *current_process.read() == Process::Login { current_process.set(Process::Registration); data.write().id = None; return; } handlers.reset_handlers(); spinner_animated.set(true); if let Err(errors) = on_register(&SESSION, data) { let field_name = PASSWORD_FIELD_NAME; let field_errors = errors.field_errors(); if let Some(errors) = field_errors.get(field_name) { let mut suggestions_msgs = Vec::::new(); errors .iter() .filter(|e| e.code == TOO_WEAK_PASSWORD_ERROR_NAME) .for_each(|e| { if let Some(suggestions_value) = e.params.get("suggestions") { if let Some(suggestion) = suggestions_value.as_array() { let msgs = suggestion.iter().map(|msg| { let msg = msg.to_string(); let without_quote_marks = &msg[1..msg.len() - 1]; without_quote_marks.to_string() }); suggestions_msgs.extend(msgs); } } }); if !suggestions_msgs.is_empty() { let mut modal_config = PasswordSuggestionsModalConfig::new( None, "Let's talk about your password".to_string(), ); modal_config .suggestions .insert(field_name, suggestions_msgs); modal_configs.write().push(modal_config); } } on_validation_errors(&field_errors, &handlers); } spinner_animated.set(false); } }; let mut classes: [&str; 2] = [ClassName::LOGIN, ""]; let mut password_classes: [&str; 2] = [ClassName::LOGIN_PASSWORD, ""]; let mut confirm_password_classes: [&str; 2] = [ClassName::LOGIN_CONFIRM_PASSWORD, ""]; match *current_process.read() { Process::Registration => { classes[1] = ClassName::REGISTER; password_classes[1] = ClassName::SHOW; confirm_password_classes[1] = ClassName::SHOW; if *id_placeholder.read() != REGISTER_ID_PLACEHOLDER { id_placeholder.set(REGISTER_ID_PLACEHOLDER); } } Process::Login => { if *id_placeholder.read() != LOGIN_ID_PLACEHOLDER { id_placeholder.set(LOGIN_ID_PLACEHOLDER); } } } let on_modal_confirm = move |_: Event| { modal_config.set(None); }; let rendered_modal = modal_config .read() .as_ref() .map(|modal_config| rsx!({ generate_modal(modal_config, on_modal_confirm) })); let classes_str = classes.join(" "); let password_classes_str = password_classes.join(" "); let confirm_password_classes_str = confirm_password_classes.join(" "); rsx! { style { {STYLE_SHEET} } div { class: "{classes_str}", div { class: ClassName::LOGIN_AVATAR, onclick: move |_| { random_avatar_future.restart() }, {avatar} } div { class: ClassName::LOGIN_HOMESERVER, TextInput { placeholder: "Homeserver URL", value: "{homeserver_url}", state: homeserver_url_state, oninput: on_input![data, homeserver_url], } } div { class: ClassName::LOGIN_ID, TextInput { placeholder: "{id_placeholder}", value: "{id}", state: id_state, oninput: on_input![data, id], } } div { class: "{password_classes_str}", PasswordTextInput { placeholder: "Password", value: "{password}", state: password_state, oninput: on_input![data, password], } } div { class: "{confirm_password_classes_str}", PasswordTextInput { placeholder: "Confirm Password", value: "{confirm_password}", state: confirm_password_state, oninput: on_input![data, confirm_password], } } div { class: ClassName::LOGIN_SPINNER, Spinner { animate: *spinner_animated.read(), } } div { class: ClassName::LOGIN_REGISTER_BUTTON, RegisterButton { onclick: on_clicked_register, } } div { class: ClassName::LOGIN_LOGIN_BUTTON, LoginButton { focus: true, onclick: on_clicked_login, } } } {rendered_modal} } }