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", "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]] [[package]]
name = "gloo-console" name = "gloo-console"
version = "0.2.3" version = "0.2.3"
@ -922,27 +903,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-render" name = "gloo-render"
version = "0.1.1" version = "0.1.1"
@ -1077,25 +1037,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-worker-macros" name = "gloo-worker-macros"
version = "0.1.0" version = "0.1.0"
@ -1845,7 +1786,7 @@ dependencies = [
"chrono-humanize", "chrono-humanize",
"ciborium", "ciborium",
"futures", "futures",
"gloo 0.11.0", "gloo 0.10.0",
"instant", "instant",
"log", "log",
"once_cell", "once_cell",

View File

@ -7,13 +7,13 @@ edition = "2024"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
thiserror = { version = "2" } thiserror = { version = "2" }
uuid = { version = "1", features = ["serde", "v4"] }
axum = { version = "*", optional = true } axum = { version = "*", optional = true }
argon2 = { version = "*", optional = true } argon2 = { version = "*", optional = true }
sqlx = { version = "*", optional = true } sqlx = { version = "*", optional = true }
ciborium = { version = "*", optional = true } ciborium = { version = "*", optional = true }
bytes = { version = "1.10.1", features = ["serde"], optional = true } bytes = { version = "1.10.1", features = ["serde"], optional = true }
axum-extra = { version = "*", optional = true } axum-extra = { version = "*", optional = true }
uuid = { version = "1", features = ["serde", "v4"] }
log = { version = "0.4", optional = true } log = { version = "0.4", optional = true }
[features] [features]

View File

@ -5,12 +5,13 @@ pub mod cbor;
pub mod error; pub mod error;
pub mod limited; pub mod limited;
pub mod message; pub mod message;
pub mod path;
pub mod plan; pub mod plan;
pub mod token; pub mod token;
pub mod user; 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}; use serde::{Deserialize, Serialize};
#[derive( #[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); pub struct ClampedString<const MIN: usize, const MAX: usize>(String);
impl<const MIN: usize, const MAX: usize> ClampedString<MIN, MAX> { 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>> { pub fn new(s: String) -> Result<Self, RangeInclusive<usize>> {
let str_len = s.chars().take(MAX.saturating_add(1)).count(); let str_len = s.chars().take(MAX.saturating_add(1)).count();
(str_len >= MIN && str_len <= MAX) (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 username: Username,
pub password: Password, pub password: Password,
} }
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ChangePassword {
pub current: Password,
pub new: Password,
}
crate::id_impl!(UserId); crate::id_impl!(UserId);

View File

@ -7,7 +7,7 @@ use chrono::{TimeDelta, Utc};
use plan_proto::{ use plan_proto::{
error::{DatabaseError, ServerError}, error::{DatabaseError, ServerError},
token, token,
user::UserId, user::{Password, UserId},
}; };
use rand::distr::SampleString; use rand::distr::SampleString;
use sqlx::{Decode, Encode, Pool, Postgres, prelude::FromRow, query, query_as}; use sqlx::{Decode, Encode, Pool, Postgres, prelude::FromRow, query, query_as};
@ -101,6 +101,38 @@ impl UserDatabase {
Ok(user) 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> { pub async fn get_user(&self, get_user_by: GetUserBy<'_>) -> Result<User> {
Ok(match get_user_by { Ok(match get_user_by {
GetUserBy::Username(username) => { GetUserBy::Username(username) => {
@ -138,11 +170,11 @@ impl UserDatabase {
}) })
} }
pub async fn login( pub async fn verify_login(
&self, &self,
username: &str, username: &str,
password: &str, password: &str,
) -> core::result::Result<LoginToken, ServerError> { ) -> core::result::Result<User, ServerError> {
let user = self.get_user(GetUserBy::Username(username)).await?; let user = self.get_user(GetUserBy::Username(username)).await?;
let parsed_hash = PasswordHash::new(&user.password_hash).map_err(DatabaseError::from)?; let parsed_hash = PasswordHash::new(&user.password_hash).map_err(DatabaseError::from)?;
@ -153,6 +185,16 @@ impl UserDatabase {
err => ServerError::DatabaseError(err.into()), 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); let token = LoginToken::new(user.id);
query!( query!(
@ -173,7 +215,7 @@ impl UserDatabase {
} }
pub async fn check_token(&self, token: &str) -> core::result::Result<User, ServerError> { pub async fn check_token(&self, token: &str) -> core::result::Result<User, ServerError> {
let token = query_as!( let token: LoginToken = query_as!(
LoginToken, LoginToken,
r#" select r#" select
token, user_id, created_at, expires_at token, user_id, created_at, expires_at

View File

@ -13,7 +13,6 @@ use axum::{
}; };
use axum_extra::{ use axum_extra::{
TypedHeader, TypedHeader,
handler::HandlerCallWithExtractors,
headers::{self, Authorization}, headers::{self, Authorization},
}; };
use core::{fmt::Display, net::SocketAddr, str::FromStr, time::Duration}; use core::{fmt::Display, net::SocketAddr, str::FromStr, time::Duration};
@ -22,18 +21,15 @@ use plan_proto::{
error::ServerError, error::ServerError,
limited::FixedLenString, limited::FixedLenString,
message::ClientMessage, message::ClientMessage,
path::PATHS,
plan::{CreatePlan, UserPlans}, plan::{CreatePlan, UserPlans},
token::{Token, TokenLogin}, token::{Token, TokenLogin},
user::UserLogin, user::{ChangePassword, UserLogin},
}; };
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use std::io::Write; use std::io::Write;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use tower::{ use tower::{ServiceBuilder, buffer::BufferLayer, limit::RateLimitLayer};
ServiceBuilder,
buffer::BufferLayer,
limit::{RateLimit, RateLimitLayer, rate::Rate},
};
use crate::{db::Database, identity::SessionIdentity, runner::Runner, session::SessionManager}; use crate::{db::Database, identity::SessionIdentity, runner::Runner, session::SessionManager};
@ -174,12 +170,12 @@ async fn main() {
}); });
let app = Router::new() let app = Router::new()
.route("/s/users", put(signup)) .route(PATHS.new_user, put(signup))
.route("/s/tokens", post(signin)) .route(PATHS.signin, post(signin))
// .route("/s/tokens/check", get(check_token)) .route(PATHS.plan_session, any(session::calendar_session))
.route("/s/plans/{id}", any(session::calendar_session)) .route(PATHS.plans, get(my_plans))
.route("/s/plans", get(my_plans)) .route(PATHS.plans, post(new_plan))
.route("/s/plans", post(new_plan)) .route(PATHS.user_password, post(change_password))
.route( .route(
"/s/tokens/check", "/s/tokens/check",
get(check_token).layer( get(check_token).layer(
@ -210,6 +206,18 @@ async fn main() {
.unwrap(); .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( async fn new_plan(
State(AppState { db, .. }): State<AppState>, State(AppState { db, .. }): State<AppState>,
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>, TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,

View File

@ -22,12 +22,14 @@ web-sys = { version = "0.3.77", features = [
"AbortController", "AbortController",
"HtmlSpanElement", "HtmlSpanElement",
"ReadableStreamDefaultReader", "ReadableStreamDefaultReader",
"HtmlDialogElement",
"DomRect",
] } ] }
log = "0.4" log = "0.4"
yew = { version = "0.21", features = ["csr"] } yew = { version = "0.21", features = ["csr"] }
yew-router = "0.18.0" yew-router = "0.18.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
gloo = "0.11" gloo = "*"
wasm-logger = "0.2" wasm-logger = "0.2"
instant = { version = "0.1", features = ["wasm-bindgen"] } instant = { version = "0.1", features = ["wasm-bindgen"] }
once_cell = "1" once_cell = "1"

View File

@ -69,7 +69,27 @@ button {
.details {} .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 { .calendar {
user-select: none; user-select: none;
@ -201,9 +221,13 @@ nav.user-nav {
width: max-content; width: max-content;
} }
.sign-out { .right-side {
position: absolute; position: absolute;
right: 20px; right: 20px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 10px;
} }
font-size: 1rem; font-size: 1rem;
@ -215,6 +239,10 @@ nav.user-nav {
} }
} }
.validation-fail {
color: red;
}
.new-plan { .new-plan {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -303,11 +331,15 @@ nav.user-nav {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
height: 200vh; height: 100vh;
width: 100vw; width: 100vw;
background-size: cover; background-size: cover;
} }
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.7);
}
.dialog { .dialog {
z-index: 5; z-index: 5;
width: 100vw; width: 100vw;
@ -321,7 +353,11 @@ nav.user-nav {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
.dialog-box { .dialog-box {
font-size: 2rem; font-size: 2rem;
border: 1px solid white; border: 1px solid white;
display: flex; display: flex;
@ -333,17 +369,34 @@ nav.user-nav {
padding-top: 10px; padding-top: 10px;
padding-bottom: 10px; padding-bottom: 10px;
background-color: black; background-color: black;
gap: 5px;
color: white;
.options { .options {
margin-top: 30px;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
flex-wrap: wrap; flex-wrap: wrap;
gap: 20px; gap: 20px;
width: 100%;
&>button { &>button {
min-width: 4cm; min-width: 4cm;
font-size: 1em; 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; 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::*; use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct DialogProps { pub struct DialogProps {
pub message: String, #[prop_or_default]
pub children: Html,
pub options: Box<[String]>, pub options: Box<[String]>,
#[prop_or_default] #[prop_or_default]
pub cancel_callback: Option<Callback<()>>, pub cancel_callback: Option<Callback<()>>,
@ -14,7 +13,7 @@ pub struct DialogProps {
#[function_component] #[function_component]
pub fn Dialog( pub fn Dialog(
DialogProps { DialogProps {
message, children,
options, options,
cancel_callback, cancel_callback,
callback, callback,
@ -43,7 +42,7 @@ pub fn Dialog(
<div class="dialog"> <div class="dialog">
<div class="dialog-box"> <div class="dialog-box">
<div class="message"> <div class="message">
<p>{message.clone()}</p> {children.clone()}
</div> </div>
<div class="options"> <div class="options">
{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)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct ErrorDisplayProps { pub struct ErrorDisplayProps {
pub state: UseStateHandle<Option<String>>, pub state: UseStateHandle<Option<String>>,
#[prop_or(true)]
pub closable: bool,
} }
#[function_component] #[function_component]
pub fn ErrorDisplay(ErrorDisplayProps { state }: &ErrorDisplayProps) -> Html { pub fn ErrorDisplay(ErrorDisplayProps { state, closable }: &ErrorDisplayProps) -> Html {
state state
.as_ref() .as_ref()
.map(|err| { .map(|err| {
@ -14,10 +16,13 @@ pub fn ErrorDisplay(ErrorDisplayProps { state }: &ErrorDisplayProps) -> Html {
let on_click = Callback::from(move |_| { let on_click = Callback::from(move |_| {
setter.set(None); setter.set(None);
}); });
let close = closable.then_some(html! {
<button onclick={on_click}>{"Ok"}</button>
});
html! { html! {
<div class="error-container"> <div class="error-container">
<span>{err.clone()}</span> <span>{err.clone()}</span>
<button onclick={on_click}>{"Ok"}</button> {close}
</div> </div>
} }
}) })

View File

@ -1,4 +1,4 @@
use core::ops::{Range, RangeInclusive}; use core::ops::RangeInclusive;
use plan_proto::HalfHour; use plan_proto::HalfHour;
use web_sys::HtmlSelectElement; 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")]); let options: Box<[String]> = Box::new([String::from("yes"), String::from("no")]);
html! { html! {
<Dialog <Dialog
message={String::from("really sign out?")}
options={options} options={options}
cancel_callback={Some(cancel_signout)} cancel_callback={Some(cancel_signout)}
callback={callback} callback={callback}
/> >
<p>{"really sign out?"}</p>
</Dialog>
} }
}); });
html! { 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> <a href="/plans/new"><button>{"new plan"}</button></a>
{dialog} {dialog}
</> </>

View File

@ -5,9 +5,11 @@ mod pages {
pub mod plan; pub mod plan;
pub mod signin; pub mod signin;
pub mod signup; pub mod signup;
pub mod user_settings_page;
} }
mod components { mod components {
pub mod dialog; pub mod dialog;
pub mod dialog_modal;
pub mod error; pub mod error;
pub mod half_hour_range_select; pub mod half_hour_range_select;
pub mod month; pub mod month;
@ -21,10 +23,7 @@ mod login;
mod request; mod request;
mod storage; mod storage;
mod weekday; mod weekday;
use core::{ use core::ops::{Bound, RangeBounds, RangeInclusive};
fmt::Display,
ops::{Bound, RangeBounds, RangeInclusive, Sub},
};
use futures::FutureExt; use futures::FutureExt;
use gloo::net::http::Request; use gloo::net::http::Request;
@ -43,6 +42,7 @@ use crate::{
plan::PlanPage, plan::PlanPage,
signin::Signin, signin::Signin,
signup::Signup, signup::Signup,
user_settings_page::UserSettingsPage,
}, },
storage::{LastPlanVisitedLoggedOut, StorageKey}, storage::{LastPlanVisitedLoggedOut, StorageKey},
}; };
@ -70,6 +70,8 @@ enum Route {
#[not_found] #[not_found]
#[at("/404")] #[at("/404")]
NotFound, NotFound,
#[at("/user/settings")]
UserSettings,
} }
fn route(route: Route) -> Html { fn route(route: Route) -> Html {
@ -167,6 +169,9 @@ fn route(route: Route) -> Html {
} }
let body = match route { let body = match route {
Route::UserSettings => html! {
<UserSettingsPage />
},
Route::Plan { id } => html! { Route::Plan { id } => html! {
<PlanPage id={id}/> <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()
)));
}
}
});
}