add password changing

This commit is contained in:
emilis 2026-01-02 19:00:24 +00:00
parent 056cb951a2
commit 68c0d0e4b3
No known key found for this signature in database
17 changed files with 587 additions and 100 deletions

61
Cargo.lock generated
View File

@ -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",

View File

@ -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]

View File

@ -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(

View File

@ -65,6 +65,9 @@ impl<const LEN: usize> Serialize for FixedLenString<LEN> {
pub struct ClampedString<const MIN: usize, const MAX: usize>(String);
impl<const MIN: usize, const MAX: usize> ClampedString<MIN, MAX> {
pub const MIN_LEN: usize = MIN;
pub const MAX_LEN: usize = MAX;
pub fn new(s: String) -> Result<Self, RangeInclusive<usize>> {
let str_len = s.chars().take(MAX.saturating_add(1)).count();
(str_len >= MIN && str_len <= MAX)

24
plan-proto/src/path.rs Normal file
View File

@ -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",
}

View File

@ -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);

View File

@ -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<User> {
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<User> {
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<LoginToken, ServerError> {
) -> core::result::Result<User, ServerError> {
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<LoginToken, ServerError> {
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<User, ServerError> {
let token = query_as!(
let token: LoginToken = query_as!(
LoginToken,
r#" select
token, user_id, created_at, expires_at

View File

@ -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<AppState>,
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,
Cbor(ChangePassword { current, new }): Cbor<ChangePassword>,
) -> Result<impl IntoResponse, ServerError> {
let user = db.user().check_token(&login.0).await?;
let user = db.user().verify_login(&user.username, &current).await?;
db.user().change_password(user, new).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn new_plan(
State(AppState { db, .. }): State<AppState>,
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,

View File

@ -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"

View File

@ -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;
}
}
}

View File

@ -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<Callback<()>>,
@ -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(
<div class="dialog">
<div class="dialog-box">
<div class="message">
<p>{message.clone()}</p>
{children.clone()}
</div>
<div class="options">
{options}

View File

@ -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::<HtmlDialogElement>().ok())
{
dialog.close()
}
})
};
let close = {
html! {
<button onclick={close_cb.clone()} class="close">{"close"}</button>
}
};
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! {
<>
<button onclick={on_confirm}>{"confirm"}</button>
{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::<HtmlDialogElement>().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! {
<dialog id={id.clone()} onclick={on_backdrop_click}>
<div class="dialog">
<div class="dialog-box">
{children.clone()}
<div class="options">
{mode_buttons}
</div>
</div>
</div>
</dialog>
};
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::<HtmlDialogElement>().ok())
&& let Err(err) = dialog.show_modal()
{
gloo::console::log!("[ERR] show dialog modal: ", err)
}
})
};
let button = (*button_content != html! {}).then_some(html! {
<button onclick={on_click}>{button_content.clone()}</button>
});
html! {
<div class="dialog-modal">
{button}
{modal}
</div>
}
}

View File

@ -3,10 +3,12 @@ use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ErrorDisplayProps {
pub state: UseStateHandle<Option<String>>,
#[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! {
<button onclick={on_click}>{"Ok"}</button>
});
html! {
<div class="error-container">
<span>{err.clone()}</span>
<button onclick={on_click}>{"Ok"}</button>
{close}
</div>
}
})

View File

@ -1,4 +1,4 @@
use core::ops::{Range, RangeInclusive};
use core::ops::RangeInclusive;
use plan_proto::HalfHour;
use web_sys::HtmlSelectElement;

View File

@ -52,16 +52,20 @@ pub fn Nav() -> Html {
let options: Box<[String]> = Box::new([String::from("yes"), String::from("no")]);
html! {
<Dialog
message={String::from("really sign out?")}
options={options}
cancel_callback={Some(cancel_signout)}
callback={callback}
/>
>
<p>{"really sign out?"}</p>
</Dialog>
}
});
html! {
<>
<button onclick={confirm_signout} class="sign-out">{"sign out"}</button>
<div class="right-side">
<a href="/user/settings"><button>{"⚙️"}</button></a>
<button onclick={confirm_signout} >{"sign out"}</button>
</div>
<a href="/plans/new"><button>{"new plan"}</button></a>
{dialog}
</>

View File

@ -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! {
<UserSettingsPage />
},
Route::Plan { id } => html! {
<PlanPage id={id}/>
},

View File

@ -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::<Token>() {
Some(token) => token,
None => {
return html! {
<SignedOutMainPage />
};
}
};
let error_state = use_state(|| None);
let error_obj = html! {
<ErrorDisplay state={error_state.clone()}/>
};
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! {
<ErrorDisplay state={pw_err.clone()} closable=false/>
};
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::<HtmlDialogElement>().ok())
{
dialog.close()
}
if let Some(dialog) = gloo::utils::document()
.get_element_by_id(SUCCESS_MODAL_ID)
.and_then(|elem| elem.dyn_into::<HtmlDialogElement>().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! {
<DialogModal
id={change_password_modal_id.clone()}
button_content={html!{{"change password"}}}
mode={confirm_mode}
close_backdrop=true
>
{pw_error_obj}
<label for="current-password">{"current password"}</label>
<StateInput
id={Some(String::from("current-password"))}
state={current_password_state.clone()}
input_type={InputType::Password}
/>
<label for="new-password">{"new password"}</label>
<StateInput
id={Some(String::from("new-password"))}
state={new_password_state}
input_type={InputType::Password}
/>
<label for="new-password-repeat">{"new password (repeat)"}</label>
<StateInput
id={Some(String::from("new-password-repeat"))}
state={new_password_repeat_state}
input_type={InputType::Password}
/>
</DialogModal>
}
};
html! {
<>
<div class="user-settings">
<DialogModal
id={SUCCESS_MODAL_ID.to_string()}
close_backdrop=true
>
<h1 class="success">
{"task completed"}
</h1>
</DialogModal>
<h1>{token.username.to_string()}</h1>
<div class="user-options">
{change_password}
</div>
</div>
{error_obj}
</>
}
}
fn change_password_request(
token: Token,
current: Password,
new: Password,
on_error: UseStateSetter<Option<String>>,
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::<ServerError, _>(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()
)));
}
}
});
}