Redesign Login component and add fields validation

This commit is contained in:
2024-03-15 12:55:13 +01:00
parent fc0d3b1212
commit ceeda1a771
4 changed files with 383 additions and 52 deletions

View File

@@ -29,7 +29,9 @@ futures-util = "0.3.29"
futures = "0.3.29" futures = "0.3.29"
rand = "0.8.5" rand = "0.8.5"
reqwest = "0.11.24" reqwest = "0.11.24"
validator = { version = "0.17.0", features = ["derive"] }
const_format = "0.2.32" const_format = "0.2.32"
zxcvbn = "2.2.2"
[build] [build]
target = "x86_64-unknown-linux-gnu" target = "x86_64-unknown-linux-gnu"

View File

@@ -9,7 +9,7 @@ The goal of this project is to propose a new open-source implementation of the f
## Back-end ## Back-end
This project is based on the [Matrix.org](https://matrix.org/) building blocks (back-end and front-end SDK) to avoid to 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/). - [Open-source protocol](https://spec.matrix.org/v1.9/).
- Features expected for a messaging solution in 2024 (multi-devices management, emojis, integrations, redaction, - Features expected for a messaging solution in 2024 (multi-devices management, emojis, integrations, redaction,

View File

@@ -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 dioxus::prelude::*;
use fermi::*; use fermi::*;
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
use validator::{Validate, ValidateArgs, ValidateEmail, ValidationError, ValidationErrors};
use zxcvbn::zxcvbn;
use crate::base::{Session, SESSION}; use crate::base::{Session, SESSION};
use super::button::{LoginButton, RegisterButton}; use super::button::{LoginButton, RegisterButton};
use super::spinner::Spinner; use super::spinner::Spinner;
use super::text_field::TextField; use super::text_input::{TextInput, TextInputState};
use super::wallpaper::Wallpaper; use super::wallpaper::Wallpaper;
@@ -70,30 +76,253 @@ async fn generate_random_avatar(url: &String) -> Option<String> {
res res
} }
struct LoginData { const REQUIRED_ERROR_NAME: &'static str = "required";
homeserver_url: Option<String>, const REQUIRED_ERROR_HELPER_TEXT: &'static str = "This field must be completed";
email: Option<String>,
password: Option<String>, 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<String>, 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<String>, 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::<Vec<_>>();
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<String>,
#[validate(required, custom(function = validate_id, use_context))]
id: Option<String>,
#[validate(required, custom(function = validate_password, use_context))]
password: Option<String>,
confirm_password: Option<String>,
}
impl Data {
fn new() -> Self { fn new() -> Self {
Self { Self {
homeserver_url: None, homeserver_url: None,
email: None, id: None,
password: None, password: None,
confirm_password: None,
} }
} }
} }
async fn on_login(session_ref: &UseAtomRef<Session>, login_ref: &UseRef<LoginData>) { fn on_login(
let login = login_ref.read(); session_ref: &UseAtomRef<Session>,
data_ref: &UseRef<Data>,
) -> Result<(), ValidationErrors> {
let login = data_ref.read();
session_ref.write().update( match login.validate_with_args(&Process::LOGIN) {
login.homeserver_url.clone(), Ok(_) => {
login.email.clone(), session_ref.write().update(
login.password.clone(), login.homeserver_url.clone(),
); login.id.clone(),
login.password.clone(),
);
Ok(())
}
Err(err) => Err(err),
}
}
fn on_register(
_session_ref: &UseAtomRef<Session>,
data_ref: &UseRef<Data>,
) -> 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<ValidationError>>,
by_field_name_states: &HashMap<&'static str, &UseRef<TextInputState>>,
) {
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::<Vec<_>>();
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::<Vec<_>>();
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<TextInputState>>) {
let _ = states.for_each(|s| s.write().reset());
} }
#[derive(Props)] #[derive(Props)]
@@ -106,14 +335,29 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> {
let session = use_atom_ref(cx, &SESSION); let session = use_atom_ref(cx, &SESSION);
let login = use_ref(cx, LoginData::new); let data_ref = use_ref(cx, Data::new);
let login_ref = login.read();
let homeserver_url = login_ref.homeserver_url.as_deref().unwrap_or(""); let current_process = use_state(cx, || Process::LOGIN);
let email = login_ref.email.as_deref().unwrap_or("");
let password = login_ref.password.as_deref().unwrap_or(""); 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<TextInputState>> = 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 spinner_animated = use_state(cx, || false);
let id_placeholder = use_state(cx, || LOGIN_ID_PLACEHOLDER);
let url = cx let url = cx
.props .props
@@ -151,18 +395,73 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> {
spinner_animated.set(false); spinner_animated.set(false);
} }
let on_login = move |_| { let on_login = {
cx.spawn({ to_owned![by_field_name_states];
to_owned![login, session, spinner_animated]; move |_| {
reset_states(by_field_name_states.values());
async move { if **current_process == Process::REGISTRATION {
spinner_animated.set(true); current_process.set(Process::LOGIN);
on_login(&session, &login).await; 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! { cx.render(rsx! {
style { STYLE_SHEET }, style { STYLE_SHEET },
@@ -172,7 +471,7 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> {
class: ClassName::ROOT, class: ClassName::ROOT,
div { div {
class: ClassName::FORM, class: "{form_classes_str}",
div { div {
class: ClassName::PHOTO, class: ClassName::PHOTO,
@@ -186,35 +485,50 @@ pub fn Login<'a>(cx: Scope<'a, LoginProps>) -> Element<'a> {
div { div {
class: ClassName::HOMESERVER, class: ClassName::HOMESERVER,
TextField { TextInput {
id: "hs_url", id: "hs_url",
r#type: "text", r#type: "text",
placeholder: "Homeserver URL", placeholder: "Homeserver URL",
value: "{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 { div {
class: ClassName::EMAIL, class: ClassName::ID,
TextField { TextInput {
id: "email", r#type: "text",
r#type: "email", placeholder: "{id_placeholder}",
placeholder: "Email", value: "{id}",
value: "{email}", state: id_state,
is_value_valid: false, oninput: move |evt: FormEvent| data_ref.write().id = if evt.value.len() > 0 {
oninput: move |evt: FormEvent| login.write().email = Some(evt.value.clone()), Some(evt.value.clone()) } else { None },
}, },
}, },
div { div {
class: ClassName::PASSWORD, class: ClassName::PASSWORD,
TextField { TextInput {
id: "password",
r#type: "password", r#type: "password",
placeholder: "Password", placeholder: "Password",
value: "{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 { div {
class: ClassName::REGISTER, class: ClassName::REGISTER,
RegisterButton { RegisterButton {
id: "register", onclick: on_register,
// TODO: Handle the registration process
}, },
}, },
div { div {
class: ClassName::LOGIN, class: ClassName::LOGIN,
LoginButton { LoginButton {
id: "login",
focus: true, focus: true,
onclick: on_login, onclick: on_login,
}, },

View File

@@ -45,16 +45,18 @@
grid-template-columns: $padding-col $edit-padding $photo-padding grid-template-columns: $padding-col $edit-padding $photo-padding
$button-overlap $profile-img-ext-width $center-width $profile-img-ext-width $button-overlap $button-overlap $profile-img-ext-width $center-width $profile-img-ext-width $button-overlap
$photo-padding $edit-padding $padding-col; $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: grid-template-areas:
". . . . . . . . . . ." ". . . . . . . . . . ."
". . . photo photo photo photo photo . . ." ". . . photo photo photo photo photo . . ."
". . . . . . . . . . ." ". . . . . . . . . . ."
". . homeserver homeserver homeserver homeserver homeserver homeserver homeserver . ." ". . 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 . . . ." ". . . . 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 { .photo {
grid-area: photo; grid-area: photo;
@@ -84,12 +92,21 @@
grid-area: homeserver; grid-area: homeserver;
} }
.email { .id {
grid-area: username; grid-area: id;
} }
.password { .password {
grid-area: status; grid-area: password;
}
.confirm-password {
grid-area: confirm;
display: none;
&.show {
display: initial;
}
} }
.spinner { .spinner {