From 68c0d0e4b3e965573a063741a601777f82cd52d4 Mon Sep 17 00:00:00 2001 From: emilis Date: Fri, 2 Jan 2026 19:00:24 +0000 Subject: [PATCH] add password changing --- Cargo.lock | 61 +---- plan-proto/Cargo.toml | 2 +- plan-proto/src/lib.rs | 5 +- plan-proto/src/limited.rs | 3 + plan-proto/src/path.rs | 24 ++ plan-proto/src/user.rs | 5 + plan-server/src/db/user.rs | 50 +++- plan-server/src/main.rs | 34 ++- plan/Cargo.toml | 4 +- plan/index.scss | 86 +++++- plan/src/components/dialog.rs | 9 +- plan/src/components/dialog_modal.rs | 126 +++++++++ plan/src/components/error.rs | 9 +- plan/src/components/half_hour_range_select.rs | 2 +- plan/src/components/nav.rs | 10 +- plan/src/main.rs | 13 +- plan/src/pages/user_settings_page.rs | 244 ++++++++++++++++++ 17 files changed, 587 insertions(+), 100 deletions(-) create mode 100644 plan-proto/src/path.rs create mode 100644 plan/src/components/dialog_modal.rs create mode 100644 plan/src/pages/user_settings_page.rs diff --git a/Cargo.lock b/Cargo.lock index 4b3998a..68c7dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,25 +737,6 @@ dependencies = [ "gloo-worker 0.4.0", ] -[[package]] -name = "gloo" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" -dependencies = [ - "gloo-console 0.3.0", - "gloo-dialogs 0.2.0", - "gloo-events 0.2.0", - "gloo-file 0.3.0", - "gloo-history 0.2.2", - "gloo-net 0.5.0", - "gloo-render 0.2.0", - "gloo-storage 0.3.0", - "gloo-timers 0.3.0", - "gloo-utils 0.2.0", - "gloo-worker 0.5.0", -] - [[package]] name = "gloo-console" version = "0.2.3" @@ -922,27 +903,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gloo-net" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils 0.2.0", - "http 0.2.12", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "gloo-render" version = "0.1.1" @@ -1077,25 +1037,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gloo-worker" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" -dependencies = [ - "bincode", - "futures", - "gloo-utils 0.2.0", - "gloo-worker-macros", - "js-sys", - "pinned", - "serde", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "gloo-worker-macros" version = "0.1.0" @@ -1845,7 +1786,7 @@ dependencies = [ "chrono-humanize", "ciborium", "futures", - "gloo 0.11.0", + "gloo 0.10.0", "instant", "log", "once_cell", diff --git a/plan-proto/Cargo.toml b/plan-proto/Cargo.toml index f132b9d..0767dce 100644 --- a/plan-proto/Cargo.toml +++ b/plan-proto/Cargo.toml @@ -7,13 +7,13 @@ edition = "2024" serde = { version = "1", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } thiserror = { version = "2" } +uuid = { version = "1", features = ["serde", "v4"] } axum = { version = "*", optional = true } argon2 = { version = "*", optional = true } sqlx = { version = "*", optional = true } ciborium = { version = "*", optional = true } bytes = { version = "1.10.1", features = ["serde"], optional = true } axum-extra = { version = "*", optional = true } -uuid = { version = "1", features = ["serde", "v4"] } log = { version = "0.4", optional = true } [features] diff --git a/plan-proto/src/lib.rs b/plan-proto/src/lib.rs index 2075244..ba0e91e 100644 --- a/plan-proto/src/lib.rs +++ b/plan-proto/src/lib.rs @@ -5,12 +5,13 @@ pub mod cbor; pub mod error; pub mod limited; pub mod message; +pub mod path; pub mod plan; pub mod token; pub mod user; -use core::{fmt::Display, ops::RangeBounds}; +use core::fmt::Display; -use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; +use chrono::{NaiveTime, Timelike}; use serde::{Deserialize, Serialize}; #[derive( diff --git a/plan-proto/src/limited.rs b/plan-proto/src/limited.rs index 5b90efe..fd25036 100644 --- a/plan-proto/src/limited.rs +++ b/plan-proto/src/limited.rs @@ -65,6 +65,9 @@ impl Serialize for FixedLenString { pub struct ClampedString(String); impl ClampedString { + pub const MIN_LEN: usize = MIN; + pub const MAX_LEN: usize = MAX; + pub fn new(s: String) -> Result> { let str_len = s.chars().take(MAX.saturating_add(1)).count(); (str_len >= MIN && str_len <= MAX) diff --git a/plan-proto/src/path.rs b/plan-proto/src/path.rs new file mode 100644 index 0000000..3a8bd76 --- /dev/null +++ b/plan-proto/src/path.rs @@ -0,0 +1,24 @@ +macro_rules! paths { + ($($name:ident: $path:literal,)*) => { + pub struct Paths { + $( + pub $name: &'static str, + )* + } + + pub const PATHS: Paths = Paths { + $( + $name: $path, + )* + }; + }; +} + +paths! { + new_user: "/s/users", + signin: "/s/tokens", + plan_session: "/s/plans/{id}", + plans: "/s/plans", + check_token: "/s/tokens/check", + user_password: "/s/user/password", +} diff --git a/plan-proto/src/user.rs b/plan-proto/src/user.rs index f7d2c79..1ece0ab 100644 --- a/plan-proto/src/user.rs +++ b/plan-proto/src/user.rs @@ -10,5 +10,10 @@ pub struct UserLogin { pub username: Username, pub password: Password, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ChangePassword { + pub current: Password, + pub new: Password, +} crate::id_impl!(UserId); diff --git a/plan-server/src/db/user.rs b/plan-server/src/db/user.rs index 181c7d2..82f5a7d 100644 --- a/plan-server/src/db/user.rs +++ b/plan-server/src/db/user.rs @@ -7,7 +7,7 @@ use chrono::{TimeDelta, Utc}; use plan_proto::{ error::{DatabaseError, ServerError}, token, - user::UserId, + user::{Password, UserId}, }; use rand::distr::SampleString; use sqlx::{Decode, Encode, Pool, Postgres, prelude::FromRow, query, query_as}; @@ -101,6 +101,38 @@ impl UserDatabase { Ok(user) } + pub async fn change_password(&self, user: User, password: Password) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt)? + .to_string(); + + let updated_at = chrono::Utc::now(); + query!( + r#" + update + users + set + password_hash = $1, + updated_at = $2 + where + id = $3 + "#, + password_hash, + updated_at, + user.id.into_uuid(), + ) + .execute(&self.pool) + .await?; + + Ok(User { + password_hash, + updated_at, + ..user + }) + } + pub async fn get_user(&self, get_user_by: GetUserBy<'_>) -> Result { Ok(match get_user_by { GetUserBy::Username(username) => { @@ -138,11 +170,11 @@ impl UserDatabase { }) } - pub async fn login( + pub async fn verify_login( &self, username: &str, password: &str, - ) -> core::result::Result { + ) -> core::result::Result { let user = self.get_user(GetUserBy::Username(username)).await?; let parsed_hash = PasswordHash::new(&user.password_hash).map_err(DatabaseError::from)?; @@ -153,6 +185,16 @@ impl UserDatabase { err => ServerError::DatabaseError(err.into()), })?; + Ok(user) + } + + pub async fn login( + &self, + username: &str, + password: &str, + ) -> core::result::Result { + let user = self.verify_login(username, password).await?; + let token = LoginToken::new(user.id); query!( @@ -173,7 +215,7 @@ impl UserDatabase { } pub async fn check_token(&self, token: &str) -> core::result::Result { - let token = query_as!( + let token: LoginToken = query_as!( LoginToken, r#" select token, user_id, created_at, expires_at diff --git a/plan-server/src/main.rs b/plan-server/src/main.rs index 6b13fd8..ff89c4b 100644 --- a/plan-server/src/main.rs +++ b/plan-server/src/main.rs @@ -13,7 +13,6 @@ use axum::{ }; use axum_extra::{ TypedHeader, - handler::HandlerCallWithExtractors, headers::{self, Authorization}, }; use core::{fmt::Display, net::SocketAddr, str::FromStr, time::Duration}; @@ -22,18 +21,15 @@ use plan_proto::{ error::ServerError, limited::FixedLenString, message::ClientMessage, + path::PATHS, plan::{CreatePlan, UserPlans}, token::{Token, TokenLogin}, - user::UserLogin, + user::{ChangePassword, UserLogin}, }; use sqlx::postgres::PgPoolOptions; use std::io::Write; use tokio::sync::mpsc::UnboundedSender; -use tower::{ - ServiceBuilder, - buffer::BufferLayer, - limit::{RateLimit, RateLimitLayer, rate::Rate}, -}; +use tower::{ServiceBuilder, buffer::BufferLayer, limit::RateLimitLayer}; use crate::{db::Database, identity::SessionIdentity, runner::Runner, session::SessionManager}; @@ -174,12 +170,12 @@ async fn main() { }); let app = Router::new() - .route("/s/users", put(signup)) - .route("/s/tokens", post(signin)) - // .route("/s/tokens/check", get(check_token)) - .route("/s/plans/{id}", any(session::calendar_session)) - .route("/s/plans", get(my_plans)) - .route("/s/plans", post(new_plan)) + .route(PATHS.new_user, put(signup)) + .route(PATHS.signin, post(signin)) + .route(PATHS.plan_session, any(session::calendar_session)) + .route(PATHS.plans, get(my_plans)) + .route(PATHS.plans, post(new_plan)) + .route(PATHS.user_password, post(change_password)) .route( "/s/tokens/check", get(check_token).layer( @@ -210,6 +206,18 @@ async fn main() { .unwrap(); } +async fn change_password( + State(AppState { db, .. }): State, + TypedHeader(Authorization(login)): TypedHeader>, + Cbor(ChangePassword { current, new }): Cbor, +) -> Result { + let user = db.user().check_token(&login.0).await?; + let user = db.user().verify_login(&user.username, ¤t).await?; + db.user().change_password(user, new).await?; + + Ok(StatusCode::NO_CONTENT) +} + async fn new_plan( State(AppState { db, .. }): State, TypedHeader(Authorization(login)): TypedHeader>, diff --git a/plan/Cargo.toml b/plan/Cargo.toml index eab9809..76d3784 100644 --- a/plan/Cargo.toml +++ b/plan/Cargo.toml @@ -22,12 +22,14 @@ web-sys = { version = "0.3.77", features = [ "AbortController", "HtmlSpanElement", "ReadableStreamDefaultReader", + "HtmlDialogElement", + "DomRect", ] } log = "0.4" yew = { version = "0.21", features = ["csr"] } yew-router = "0.18.0" serde = { version = "1.0", features = ["derive"] } -gloo = "0.11" +gloo = "*" wasm-logger = "0.2" instant = { version = "0.1", features = ["wasm-bindgen"] } once_cell = "1" diff --git a/plan/index.scss b/plan/index.scss index 22c3ca3..68d653c 100644 --- a/plan/index.scss +++ b/plan/index.scss @@ -69,7 +69,27 @@ button { .details {} } -.error-container {} +.error-container { + $error_border: rgba(255, 0, 0, 0.7); + padding: 3px 5px 3px 5px; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + align-items: center; + background-color: rgba(255, 0, 0, 0.1); + border: 1px solid rgba(255, 0, 0, 0.3); + text-align: center; + + &>button { + border: 1px solid $error_border; + color: $error_border; + background-color: color.change($error_border, $alpha: 0.1); + + &:hover { + background-color: color.change($error_border, $alpha: 0.3); + } + } +} .calendar { user-select: none; @@ -201,9 +221,13 @@ nav.user-nav { width: max-content; } - .sign-out { + .right-side { position: absolute; right: 20px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 10px; } font-size: 1rem; @@ -215,6 +239,10 @@ nav.user-nav { } } +.validation-fail { + color: red; +} + .new-plan { display: flex; flex-direction: column; @@ -303,11 +331,15 @@ nav.user-nav { position: fixed; top: 0; left: 0; - height: 200vh; + height: 100vh; width: 100vw; background-size: cover; } +dialog::backdrop { + background-color: rgba(0, 0, 0, 0.7); +} + .dialog { z-index: 5; width: 100vw; @@ -321,7 +353,11 @@ nav.user-nav { align-items: center; justify-content: center; + + .dialog-box { + + font-size: 2rem; border: 1px solid white; display: flex; @@ -333,17 +369,34 @@ nav.user-nav { padding-top: 10px; padding-bottom: 10px; background-color: black; + gap: 5px; + color: white; .options { + margin-top: 30px; display: flex; - flex-direction: row; + flex-direction: column; flex-wrap: wrap; gap: 20px; + width: 100%; &>button { min-width: 4cm; font-size: 1em; } + + $close_color: rgba(255, 0, 0, 1); + + .close { + border: 1px solid $close_color; + color: $close_color; + background-color: change-color($color: $close_color, $alpha: 0.1); + ; + + &:hover { + background-color: change-color($color: $close_color, $alpha: 0.4); + } + } } } } @@ -649,3 +702,28 @@ select { color: black; } } + +#change-password { + input { + font-size: 1em; + } +} + +.user-settings { + width: 100%; + + .user-options { + list-style: none; + display: flex; + flex-direction: column; + gap: 10px; + padding-left: 0; + width: 100%; + align-items: center; + + button { + font-size: 1.5em; + padding: 10px; + } + } +} diff --git a/plan/src/components/dialog.rs b/plan/src/components/dialog.rs index 3a699e8..3d23d3c 100644 --- a/plan/src/components/dialog.rs +++ b/plan/src/components/dialog.rs @@ -1,10 +1,9 @@ -use std::collections::HashMap; - use yew::prelude::*; #[derive(Debug, Clone, PartialEq, Properties)] pub struct DialogProps { - pub message: String, + #[prop_or_default] + pub children: Html, pub options: Box<[String]>, #[prop_or_default] pub cancel_callback: Option>, @@ -14,7 +13,7 @@ pub struct DialogProps { #[function_component] pub fn Dialog( DialogProps { - message, + children, options, cancel_callback, callback, @@ -43,7 +42,7 @@ pub fn Dialog(
-

{message.clone()}

+ {children.clone()}
{options} diff --git a/plan/src/components/dialog_modal.rs b/plan/src/components/dialog_modal.rs new file mode 100644 index 0000000..bc67d58 --- /dev/null +++ b/plan/src/components/dialog_modal.rs @@ -0,0 +1,126 @@ +use wasm_bindgen::JsCast; +use web_sys::HtmlDialogElement; +use yew::prelude::*; + +#[derive(Debug, Clone, PartialEq, Default)] +pub enum DialogMode { + #[default] + Close, + ConfirmOrClose(Callback<()>), +} + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct DialogProps { + pub id: String, + #[prop_or_default] + pub children: Html, + #[prop_or_default] + pub button_content: Html, + #[prop_or_default] + pub mode: DialogMode, + #[prop_or_default] + pub close_backdrop: bool, +} +#[function_component] +pub fn DialogModal( + DialogProps { + id, + children, + button_content, + mode, + close_backdrop, + }: &DialogProps, +) -> Html { + let close_cb = { + let id = id.clone(); + Callback::from(move |_| { + if let Some(dialog) = gloo::utils::document() + .get_element_by_id(&id) + .and_then(|elem| elem.dyn_into::().ok()) + { + dialog.close() + } + }) + }; + let close = { + html! { + + } + }; + let mode_buttons = match mode { + DialogMode::Close => close.clone(), + DialogMode::ConfirmOrClose(on_confirm) => { + let on_confirm = { + let cb = on_confirm.clone(); + Callback::from(move |_| cb.emit(())) + }; + + html! { + <> + + {close.clone()} + + } + } + }; + let on_backdrop_click = close_backdrop.then_some({ + let id = id.clone(); + let close_cb = close_cb.clone(); + Callback::from(move |ev: MouseEvent| { + let Some(dialog) = gloo::utils::document() + .get_element_by_id(&id) + .and_then(|elem| elem.dyn_into::().ok()) + else { + return; + }; + let Ok(Some(dialog)) = dialog.query_selector(".dialog-box") else { + return; + }; + let rect = dialog.get_bounding_client_rect(); + + let is_in_dialog = rect.top() as i32 <= ev.client_y() + && ev.client_y() <= rect.top() as i32 + rect.height() as i32 + && rect.left() as i32 <= ev.client_x() + && ev.client_x() <= rect.left() as i32 + rect.width() as i32; + + if !is_in_dialog { + close_cb.emit(ev); + } + }) + }); + let modal = html! { + +
+
+ {children.clone()} +
+ {mode_buttons} +
+
+
+
+ }; + + let on_click = { + let id = id.clone(); + Callback::from(move |_| { + if let Some(dialog) = gloo::utils::document() + .get_element_by_id(&id) + .and_then(|elem| elem.dyn_into::().ok()) + && let Err(err) = dialog.show_modal() + { + gloo::console::log!("[ERR] show dialog modal: ", err) + } + }) + }; + let button = (*button_content != html! {}).then_some(html! { + + }); + + html! { +
+ {button} + {modal} +
+ } +} diff --git a/plan/src/components/error.rs b/plan/src/components/error.rs index b400d6a..400dd0a 100644 --- a/plan/src/components/error.rs +++ b/plan/src/components/error.rs @@ -3,10 +3,12 @@ use yew::prelude::*; #[derive(Debug, Clone, PartialEq, Properties)] pub struct ErrorDisplayProps { pub state: UseStateHandle>, + #[prop_or(true)] + pub closable: bool, } #[function_component] -pub fn ErrorDisplay(ErrorDisplayProps { state }: &ErrorDisplayProps) -> Html { +pub fn ErrorDisplay(ErrorDisplayProps { state, closable }: &ErrorDisplayProps) -> Html { state .as_ref() .map(|err| { @@ -14,10 +16,13 @@ pub fn ErrorDisplay(ErrorDisplayProps { state }: &ErrorDisplayProps) -> Html { let on_click = Callback::from(move |_| { setter.set(None); }); + let close = closable.then_some(html! { + + }); html! {
{err.clone()} - + {close}
} }) diff --git a/plan/src/components/half_hour_range_select.rs b/plan/src/components/half_hour_range_select.rs index ed42958..aef77df 100644 --- a/plan/src/components/half_hour_range_select.rs +++ b/plan/src/components/half_hour_range_select.rs @@ -1,4 +1,4 @@ -use core::ops::{Range, RangeInclusive}; +use core::ops::RangeInclusive; use plan_proto::HalfHour; use web_sys::HtmlSelectElement; diff --git a/plan/src/components/nav.rs b/plan/src/components/nav.rs index 3dc6f53..8316189 100644 --- a/plan/src/components/nav.rs +++ b/plan/src/components/nav.rs @@ -52,16 +52,20 @@ pub fn Nav() -> Html { let options: Box<[String]> = Box::new([String::from("yes"), String::from("no")]); html! { + > +

{"really sign out?"}

+
} }); html! { <> - +
+ + +
{dialog} diff --git a/plan/src/main.rs b/plan/src/main.rs index f1e4ef8..e7c517b 100644 --- a/plan/src/main.rs +++ b/plan/src/main.rs @@ -5,9 +5,11 @@ mod pages { pub mod plan; pub mod signin; pub mod signup; + pub mod user_settings_page; } mod components { pub mod dialog; + pub mod dialog_modal; pub mod error; pub mod half_hour_range_select; pub mod month; @@ -21,10 +23,7 @@ mod login; mod request; mod storage; mod weekday; -use core::{ - fmt::Display, - ops::{Bound, RangeBounds, RangeInclusive, Sub}, -}; +use core::ops::{Bound, RangeBounds, RangeInclusive}; use futures::FutureExt; use gloo::net::http::Request; @@ -43,6 +42,7 @@ use crate::{ plan::PlanPage, signin::Signin, signup::Signup, + user_settings_page::UserSettingsPage, }, storage::{LastPlanVisitedLoggedOut, StorageKey}, }; @@ -70,6 +70,8 @@ enum Route { #[not_found] #[at("/404")] NotFound, + #[at("/user/settings")] + UserSettings, } fn route(route: Route) -> Html { @@ -167,6 +169,9 @@ fn route(route: Route) -> Html { } let body = match route { + Route::UserSettings => html! { + + }, Route::Plan { id } => html! { }, diff --git a/plan/src/pages/user_settings_page.rs b/plan/src/pages/user_settings_page.rs new file mode 100644 index 0000000..16af344 --- /dev/null +++ b/plan/src/pages/user_settings_page.rs @@ -0,0 +1,244 @@ +use gloo::net::http::Request; +use plan_proto::{ + error::ServerError, + path::PATHS, + token::Token, + user::{ChangePassword, Password}, +}; +use wasm_bindgen::JsCast; +use web_sys::HtmlDialogElement; +use yew::prelude::*; + +use crate::{ + SERVER_URL, + components::{ + dialog_modal::{DialogModal, DialogMode}, + error::ErrorDisplay, + state_input::{InputType, StateInput}, + }, + pages::mainpage::SignedOutMainPage, +}; + +#[function_component] +pub fn UserSettingsPage() -> Html { + const SUCCESS_MODAL_ID: &str = "success-modal"; + let token = match use_context::() { + Some(token) => token, + None => { + return html! { + + }; + } + }; + let error_state = use_state(|| None); + let error_obj = html! { + + }; + + let current_password_state = use_state(String::new); + let new_password_state = use_state(String::new); + let new_password_repeat_state = use_state(String::new); + let change_password = { + let change_password_modal_id = "change-password".to_string(); + let pw_err = use_state(|| None); + let pw_error_obj = html! { + + }; + let confirm_mode = { + let current_password_state = current_password_state.clone(); + let new_password_state = new_password_state.clone(); + let new_password_repeat_state = new_password_repeat_state.clone(); + let error_state = pw_err.clone(); + let token = token.clone(); + let change_password_modal_id = change_password_modal_id.clone(); + + DialogMode::ConfirmOrClose(Callback::from(move |_| { + pw_err.set(None); + if current_password_state.is_empty() { + error_state.set(Some("current password required".to_string())); + return; + // error_state.set(Some("".to_string())); + } + if new_password_state.is_empty() { + error_state.set(Some("new password required".to_string())); + return; + } + if *new_password_state != *new_password_repeat_state { + error_state.set(Some("new passwords do not match".to_string())); + return; + } + if new_password_state.len() < Password::MIN_LEN { + error_state.set(Some(format!( + "password too short; must be at least {} characters", + Password::MIN_LEN + ))); + return; + } + if new_password_state.len() > Password::MAX_LEN { + error_state.set(Some(format!( + "password too long; must be at most {} characters", + Password::MAX_LEN + ))); + return; + } + let Ok(current) = Password::new((*current_password_state).clone()) else { + error_state.set(Some(format!( + "current password must be between {} and {} characters", + Password::MIN_LEN, + Password::MAX_LEN, + ))); + return; + }; + let Ok(new) = Password::new((*new_password_state).clone()) else { + error_state.set(Some(format!( + "new password must be between {} and {} characters", + Password::MIN_LEN, + Password::MAX_LEN, + ))); + return; + }; + let on_success = { + let change_password_modal_id = change_password_modal_id.clone(); + Callback::from(move |_| { + if let Some(dialog) = gloo::utils::document() + .get_element_by_id(&change_password_modal_id) + .and_then(|elem| elem.dyn_into::().ok()) + { + dialog.close() + } + if let Some(dialog) = gloo::utils::document() + .get_element_by_id(SUCCESS_MODAL_ID) + .and_then(|elem| elem.dyn_into::().ok()) + && let Err(err) = dialog.show_modal() + { + gloo::console::log!("[ERR] show dialog modal: ", err) + } + }) + }; + + change_password_request( + token.clone(), + current, + new, + error_state.setter(), + on_success, + ); + })) + }; + html! { + + {pw_error_obj} + + + + + + + + } + }; + + html! { + <> + + {error_obj} + + } +} + +fn change_password_request( + token: Token, + current: Password, + new: Password, + on_error: UseStateSetter>, + on_success: Callback<()>, +) { + let token = token.clone(); + let current = current.clone(); + let new = new.clone(); + let on_error = on_error.clone(); + let mut request = vec![]; + if let Err(err) = ciborium::into_writer(&ChangePassword { current, new }, &mut request) { + on_error.set(Some(format!("serializing: {err}"))); + return; + } + let on_success = on_success.clone(); + + yew::platform::spawn_local(async move { + let req = match Request::post(format!("{SERVER_URL}{}", PATHS.user_password).as_str()) + .header("content-type", crate::CBOR_CONTENT_TYPE) + .header("authorization", format!("Bearer {}", token.token).as_str()) + .body(request) + { + Ok(req) => req, + Err(err) => { + on_error.set(Some(format!("creating login request: {err}"))); + return; + } + }; + let resp = match req.send().await { + Ok(resp) => resp, + Err(err) => { + on_error.set(Some(format!("sending password change request: {err}"))); + return; + } + }; + if resp.ok() { + on_success.emit(()); + return; + } + match resp + .binary() + .await + .map_err(|err| err.to_string()) + .and_then(|body| { + ciborium::from_reader::(body.as_slice()) + .map_err(|err| err.to_string()) + }) { + Ok(err) => { + on_error.set(Some(format!( + "[{} {}]: {err}", + resp.status(), + resp.status_text() + ))); + } + Err(err) => { + on_error.set(Some(format!( + "[{} {}] could not parse body: {err}", + resp.status(), + resp.status_text() + ))); + } + } + }); +}