add password changing
This commit is contained in:
parent
056cb951a2
commit
68c0d0e4b3
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, ¤t).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>>,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use core::ops::{Range, RangeInclusive};
|
||||
use core::ops::RangeInclusive;
|
||||
|
||||
use plan_proto::HalfHour;
|
||||
use web_sys::HtmlSelectElement;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue