From 99244264cbbe029d2d797161276addc916306e7b Mon Sep 17 00:00:00 2001 From: Valentin Tolmer <valentin.tolmer@gmail.com> Date: Tue, 12 Oct 2021 05:02:20 +0200 Subject: [PATCH] app: Migrate change password to yew_form Also disable submit while the password is being sent. --- app/src/components/change_password.rs | 252 ++++++++++++++++---------- app/src/components/create_user.rs | 2 +- app/src/components/login.rs | 8 +- 3 files changed, 162 insertions(+), 100 deletions(-) diff --git a/app/src/components/change_password.rs b/app/src/components/change_password.rs index e0b6343..b591ba2 100644 --- a/app/src/components/change_password.rs +++ b/app/src/components/change_password.rs @@ -4,11 +4,17 @@ use crate::{ }; use anyhow::{anyhow, bail, Context, Result}; use lldap_auth::*; -use wasm_bindgen::JsCast; +use validator_derive::Validate; use yew::{ prelude::*, services::{fetch::FetchTask, ConsoleService}, }; +use yew_form::Form; +use yew_form_derive::Model; +use yew_router::{ + agent::{RouteAgentDispatcher, RouteRequest}, + route::Route, +}; #[derive(PartialEq, Eq)] enum OpaqueData { @@ -29,16 +35,37 @@ impl OpaqueData { } } +/// The fields of the form, with the constraints. +#[derive(Model, Validate, PartialEq, Clone, Default)] +pub struct FormModel { + #[validate(custom( + function = "empty_or_long", + message = "Password should be longer than 8 characters" + ))] + old_password: String, + #[validate(length(min = 8, message = "Invalid password. Min length: 8"))] + password: String, + #[validate(must_match(other = "password", message = "Passwords must match"))] + confirm_password: String, +} + +fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> { + if value.is_empty() || value.len() >= 8 { + Ok(()) + } else { + Err(validator::ValidationError::new("")) + } +} + pub struct ChangePasswordForm { link: ComponentLink<Self>, - username: String, + props: Props, error: Option<anyhow::Error>, - node_ref: NodeRef, + form: Form<FormModel>, opaque_data: OpaqueData, - successfully_changed_password: bool, - is_admin: bool, // Used to keep the request alive long enough. - _task: Option<FetchTask>, + task: Option<FetchTask>, + route_dispatcher: RouteAgentDispatcher, } #[derive(Clone, PartialEq, Properties)] @@ -48,6 +75,7 @@ pub struct Props { } pub enum Msg { + FormUpdate, Submit, AuthenticationStartResponse(Result<Box<login::ServerLoginStartResponse>>), SubmitNewPassword, @@ -55,71 +83,37 @@ pub enum Msg { RegistrationFinishResponse(Result<()>), } -fn get_form_field(field_id: &str) -> Option<String> { - let document = web_sys::window()?.document()?; - Some( - document - .get_element_by_id(field_id)? - .dyn_into::<web_sys::HtmlInputElement>() - .ok()? - .value(), - ) -} - -fn clear_form_fields() -> Option<()> { - let document = web_sys::window()?.document()?; - - let clear_field = |id| { - document - .get_element_by_id(id)? - .dyn_into::<web_sys::HtmlInputElement>() - .ok()? - .set_value(""); - Some(()) - }; - clear_field("oldPassword"); - clear_field("newPassword"); - clear_field("confirmPassword"); - None -} - impl ChangePasswordForm { - fn set_error(&mut self, error: anyhow::Error) { - ConsoleService::error(&error.to_string()); - self.error = Some(error); - } - fn call_backend<M, Req, C, Resp>(&mut self, method: M, req: Req, callback: C) -> Result<()> where M: Fn(Req, Callback<Resp>) -> Result<FetchTask>, C: Fn(Resp) -> <Self as Component>::Message + 'static, { - self._task = Some(method(req, self.link.callback(callback))?); + self.task = Some(method(req, self.link.callback(callback))?); Ok(()) } - fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<()> { + fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> { match msg { + Msg::FormUpdate => Ok(true), Msg::Submit => { - let old_password = get_form_field("oldPassword") - .ok_or_else(|| anyhow!("Could not get old password from form"))?; - let new_password = get_form_field("newPassword") - .ok_or_else(|| anyhow!("Could not get new password from form"))?; - let confirm_password = get_form_field("confirmPassword") - .ok_or_else(|| anyhow!("Could not get confirmation password from form"))?; - if new_password != confirm_password { - bail!("Confirmation password doesn't match"); + if !self.form.validate() { + bail!("Check the form for errors"); } - if self.is_admin { + if self.props.is_admin { self.handle_message(Msg::SubmitNewPassword) } else { + let old_password = self.form.model().old_password; + if old_password.is_empty() { + bail!("Current password should not be empty"); + } let mut rng = rand::rngs::OsRng; let login_start_request = opaque::client::login::start_login(&old_password, &mut rng) .context("Could not initialize login")?; self.opaque_data = OpaqueData::Login(login_start_request.state); let req = login::ClientLoginStartRequest { - username: self.username.clone(), + username: self.props.username.clone(), login_start_request: login_start_request.message, }; self.call_backend( @@ -127,7 +121,7 @@ impl ChangePasswordForm { req, Msg::AuthenticationStartResponse, )?; - Ok(()) + Ok(true) } } Msg::AuthenticationStartResponse(res) => { @@ -152,13 +146,12 @@ impl ChangePasswordForm { } Msg::SubmitNewPassword => { let mut rng = rand::rngs::OsRng; - let new_password = get_form_field("newPassword") - .ok_or_else(|| anyhow!("Could not get new password from form"))?; + let new_password = self.form.model().password; let registration_start_request = opaque::client::registration::start_registration(&new_password, &mut rng) .context("Could not initiate password change")?; let req = registration::ClientRegistrationStartRequest { - username: self.username.clone(), + username: self.props.username.clone(), registration_start_request: registration_start_request.message, }; self.opaque_data = OpaqueData::Registration(registration_start_request.state); @@ -167,7 +160,7 @@ impl ChangePasswordForm { req, Msg::RegistrationStartResponse, )?; - Ok(()) + Ok(true) } Msg::RegistrationStartResponse(res) => { let res = res.context("Could not initiate password change")?; @@ -192,14 +185,19 @@ impl ChangePasswordForm { ) } _ => panic!("Unexpected data in opaque_data field"), - } + }?; + Ok(false) } Msg::RegistrationFinishResponse(response) => { + self.task = None; if response.is_ok() { - self.successfully_changed_password = true; - clear_form_fields(); + self.route_dispatcher + .send(RouteRequest::ChangeRoute(Route::from( + AppRoute::UserDetails(self.props.username.clone()), + ))); } - response + response?; + Ok(true) } } } @@ -212,23 +210,26 @@ impl Component for ChangePasswordForm { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { ChangePasswordForm { link, - username: props.username, + props, error: None, - node_ref: NodeRef::default(), + form: yew_form::Form::<FormModel>::new(FormModel::default()), opaque_data: OpaqueData::None, - successfully_changed_password: false, - is_admin: props.is_admin, - _task: None, + task: None, + route_dispatcher: RouteAgentDispatcher::new(), } } fn update(&mut self, msg: Self::Message) -> ShouldRender { - self.successfully_changed_password = false; self.error = None; - if let Err(e) = self.handle_message(msg) { - self.set_error(e); + match self.handle_message(msg) { + Err(e) => { + ConsoleService::error(&e.to_string()); + self.error = Some(e); + self.task = None; + true + } + Ok(b) => b, } - true } fn change(&mut self, _: Self::Properties) -> ShouldRender { @@ -236,38 +237,97 @@ impl Component for ChangePasswordForm { } fn view(&self) -> Html { - let is_admin = self.is_admin; + let is_admin = self.props.is_admin; + type Field = yew_form::Field<FormModel>; html! { - <form ref=self.node_ref.clone() onsubmit=self.link.callback(|e: FocusEvent| { e.prevent_default(); Msg::Submit })> - <div> - <label for="oldPassword">{"Old password:"}</label> - <input type="password" id="oldPassword" autocomplete="current-password" required=true disabled=is_admin /> + <> + <form + class="form"> + {if !is_admin { html! { + <div class="form-group row"> + <label for="old_password" + class="form-label col-sm-2 col-form-label"> + {"Current password*:"} + </label> + <div class="col-sm-10"> + <Field + form=&self.form + field_name="old_password" + class="form-control" + class_invalid="is-invalid has-error" + class_valid="has-success" + autocomplete="current-password" + oninput=self.link.callback(|_| Msg::FormUpdate) /> + <div class="invalid-feedback"> + {&self.form.field_message("old_password")} + </div> + </div> </div> - <div> - <label for="newPassword">{"New password:"}</label> - <input type="password" id="newPassword" autocomplete="new-password" required=true minlength="8" /> + }} else { html! {} }} + <div class="form-group row"> + <label for="new_password" + class="form-label col-sm-2 col-form-label"> + {"New password*:"} + </label> + <div class="col-sm-10"> + <Field + form=&self.form + field_name="password" + class="form-control" + class_invalid="is-invalid has-error" + class_valid="has-success" + autocomplete="new-password" + oninput=self.link.callback(|_| Msg::FormUpdate) /> + <div class="invalid-feedback"> + {&self.form.field_message("password")} + </div> </div> - <div> - <label for="confirmPassword">{"Confirm new password:"}</label> - <input type="password" id="confirmPassword" autocomplete="new-password" required=true minlength="8" /> - </div> - <button type="submit">{"Submit"}</button> - <div> - { if let Some(e) = &self.error { - html! { e.to_string() } - } else if self.successfully_changed_password { - html! { - <div> - <span>{"Successfully changed the password"}</span> - </div> - } - } else { html! {} } - } - </div> - <div> - <NavButton route=AppRoute::UserDetails(self.username.clone())>{"Back"}</NavButton> + </div> + <div class="form-group row"> + <label for="confirm_password" + class="form-label col-sm-2 col-form-label"> + {"Confirm password*:"} + </label> + <div class="col-sm-10"> + <Field + form=&self.form + field_name="confirm_password" + class="form-control" + class_invalid="is-invalid has-error" + class_valid="has-success" + autocomplete="new-password" + oninput=self.link.callback(|_| Msg::FormUpdate) /> + <div class="invalid-feedback"> + {&self.form.field_message("confirm_password")} + </div> </div> + </div> + <div class="form-group row"> + <button + class="btn btn-primary col-sm-1 col-form-label" + type="submit" + disabled=self.task.is_some() + onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})> + {"Submit"} + </button> + </div> </form> + { if let Some(e) = &self.error { + html! { + <div class="alert alert-danger"> + {e.to_string() } + </div> + } + } else { html! {} } + } + <div> + <NavButton + classes="btn btn-primary" + route=AppRoute::UserDetails(self.props.username.clone())> + {"Back"} + </NavButton> + </div> + </> } } } diff --git a/app/src/components/create_user.rs b/app/src/components/create_user.rs index 3dd59bd..0540f5f 100644 --- a/app/src/components/create_user.rs +++ b/app/src/components/create_user.rs @@ -340,7 +340,7 @@ impl Component for CreateUserForm { <div class="form-group row"> <button class="btn btn-primary col-sm-1 col-form-label" - type="button" + type="submit" onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})> {"Submit"} </button> diff --git a/app/src/components/login.rs b/app/src/components/login.rs index eb65d71..f66349a 100644 --- a/app/src/components/login.rs +++ b/app/src/components/login.rs @@ -2,8 +2,10 @@ use crate::infra::api::HostService; use anyhow::{anyhow, bail, Context, Result}; use lldap_auth::*; use validator_derive::Validate; -use yew::prelude::*; -use yew::services::{fetch::FetchTask, ConsoleService}; +use yew::{ + prelude::*, + services::{fetch::FetchTask, ConsoleService}, +}; use yew_form::Form; use yew_form_derive::Model; @@ -48,7 +50,7 @@ impl LoginForm { Msg::Update => Ok(true), Msg::Submit => { if !self.form.validate() { - bail!("Invalid inputs"); + bail!("Check the form for errors"); } let FormModel { username, password } = self.form.model(); let mut rng = rand::rngs::OsRng;