diff --git a/Cargo.lock b/Cargo.lock index b75521c..dc6bccf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1798,6 +1798,7 @@ dependencies = [ "rand 0.9.2", "serde", "thiserror 2.0.17", + "uuid", "wasm-bindgen", "wasm-bindgen-futures", "wasm-logger", @@ -2896,13 +2897,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] diff --git a/migrations/2_delete_cascade.sql b/migrations/2_delete_cascade.sql new file mode 100644 index 0000000..498f4e6 --- /dev/null +++ b/migrations/2_delete_cascade.sql @@ -0,0 +1,20 @@ +alter table plans + drop constraint plans_created_by_fkey, + add constraint plans_created_by_fkey + foreign key (created_by) + references users(id) + on delete cascade; + +alter table login_tokens + drop constraint login_tokens_user_id_fkey, + add constraint login_tokens_user_id_fkey + foreign key (user_id) + references users(id) + on delete cascade; + +alter table day_tiles + drop constraint day_tiles_user_id_fkey, + add constraint day_tiles_user_id_fkey + foreign key (user_id) + references users(id) + on delete cascade; diff --git a/plan-proto/src/path.rs b/plan-proto/src/path.rs index 3a8bd76..2388a6c 100644 --- a/plan-proto/src/path.rs +++ b/plan-proto/src/path.rs @@ -21,4 +21,5 @@ paths! { plans: "/s/plans", check_token: "/s/tokens/check", user_password: "/s/user/password", + delete_user: "/s/user/delete", } diff --git a/plan-proto/src/user.rs b/plan-proto/src/user.rs index 1ece0ab..d87c45c 100644 --- a/plan-proto/src/user.rs +++ b/plan-proto/src/user.rs @@ -16,4 +16,9 @@ pub struct ChangePassword { pub new: Password, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct DeleteUserRequest { + pub password: Password, +} + crate::id_impl!(UserId); diff --git a/plan-server/src/db/user.rs b/plan-server/src/db/user.rs index 82f5a7d..02b7b8b 100644 --- a/plan-server/src/db/user.rs +++ b/plan-server/src/db/user.rs @@ -101,6 +101,21 @@ impl UserDatabase { Ok(user) } + pub async fn delete_user(&self, user: User) -> Result<()> { + query!( + r#" + delete from + users + where + id = $1 + "#, + user.id.into_uuid(), + ) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn change_password(&self, user: User, password: Password) -> Result { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); diff --git a/plan-server/src/main.rs b/plan-server/src/main.rs index ff89c4b..c98bd40 100644 --- a/plan-server/src/main.rs +++ b/plan-server/src/main.rs @@ -24,7 +24,7 @@ use plan_proto::{ path::PATHS, plan::{CreatePlan, UserPlans}, token::{Token, TokenLogin}, - user::{ChangePassword, UserLogin}, + user::{ChangePassword, DeleteUserRequest, UserLogin}, }; use sqlx::postgres::PgPoolOptions; use std::io::Write; @@ -176,6 +176,7 @@ async fn main() { .route(PATHS.plans, get(my_plans)) .route(PATHS.plans, post(new_plan)) .route(PATHS.user_password, post(change_password)) + .route(PATHS.delete_user, post(delete_user)) .route( "/s/tokens/check", get(check_token).layer( @@ -206,6 +207,18 @@ async fn main() { .unwrap(); } +async fn delete_user( + State(AppState { db, .. }): State, + TypedHeader(Authorization(login)): TypedHeader>, + Cbor(DeleteUserRequest { password }): Cbor, +) -> Result { + let user = db.user().check_token(&login.0).await?; + db.user().verify_login(&user.username, &password).await?; + db.user().delete_user(user).await?; + + Ok(StatusCode::NO_CONTENT) +} + async fn change_password( State(AppState { db, .. }): State, TypedHeader(Authorization(login)): TypedHeader>, diff --git a/plan/Cargo.toml b/plan/Cargo.toml index f350219..2d36bc4 100644 --- a/plan/Cargo.toml +++ b/plan/Cargo.toml @@ -46,3 +46,4 @@ ciborium = { version = "0.2" } chrono-humanize = { version = "0.2.3", features = ["wasmbind"] } rand = { version = "0.9" } getrandom = { version = "0.3", features = ["wasm_js"] } +uuid = { version = "1.19", features = ["v4"] } diff --git a/plan/index.scss b/plan/index.scss index 5c87a2f..b7c3b82 100644 --- a/plan/index.scss +++ b/plan/index.scss @@ -371,6 +371,11 @@ dialog::backdrop { font-size: 1.5em; + input { + font-size: 1em; + width: 60vw; + } + .options { margin-top: 30px; display: flex; @@ -738,10 +743,15 @@ select { align-items: center; button { + min-width: 40vw; font-size: 1.5em; padding: 10px; } } + + .delete-account { + color: red; + } } .notification { @@ -763,3 +773,12 @@ dialog:has(.notification) { .legal-share-button { font-size: 1.5em; } + +.delete-account-info { + max-width: 50vw; + text-wrap: wrap; + text-align: center; + border: 1px solid red; + padding: 10px; + background-color: rgba(255, 0, 0, 0.3); +} diff --git a/plan/src/components/dialog_modal.rs b/plan/src/components/dialog_modal.rs index bc67d58..6ec4b6e 100644 --- a/plan/src/components/dialog_modal.rs +++ b/plan/src/components/dialog_modal.rs @@ -1,3 +1,4 @@ +use rand::distr::Distribution; use wasm_bindgen::JsCast; use web_sys::HtmlDialogElement; use yew::prelude::*; @@ -11,6 +12,7 @@ pub enum DialogMode { #[derive(Debug, Clone, PartialEq, Properties)] pub struct DialogProps { + #[prop_or_default] pub id: String, #[prop_or_default] pub children: Html, @@ -31,6 +33,12 @@ pub fn DialogModal( close_backdrop, }: &DialogProps, ) -> Html { + // use generated id if not supplied + let id = if id.is_empty() { + uuid::Uuid::new_v4().to_string() + } else { + id.to_string() + }; let close_cb = { let id = id.clone(); Callback::from(move |_| { diff --git a/plan/src/pages/signup.rs b/plan/src/pages/signup.rs index 75fe529..cd9e382 100644 --- a/plan/src/pages/signup.rs +++ b/plan/src/pages/signup.rs @@ -128,23 +128,9 @@ pub fn Signup() -> Html { return; } let id = target.id(); - match id.as_str() { - "username" => { - if let Some(password) = target - .parent_element() - .and_then(|parent| parent.query_selector("#password").ok().flatten()) - .and_then(|password| password.dyn_into::().ok()) - && let Err(err) = password.focus().map_err(Into::::into) - { - log::error!("{err}"); - } - return; - } - "password" => {} - _ => return, - } - - if let Ok(ev) = MouseEvent::new("click") { + if id.as_str() == "username" + && let Ok(ev) = MouseEvent::new("click") + { on_submit.emit(ev); }; }) diff --git a/plan/src/pages/user_settings_page.rs b/plan/src/pages/user_settings_page.rs index c59b986..a0305ee 100644 --- a/plan/src/pages/user_settings_page.rs +++ b/plan/src/pages/user_settings_page.rs @@ -3,7 +3,7 @@ use plan_proto::{ error::ServerError, path::PATHS, token::Token, - user::{ChangePassword, Password}, + user::{ChangePassword, DeleteUserRequest, Password}, }; use wasm_bindgen::JsCast; use web_sys::HtmlDialogElement; @@ -36,20 +36,20 @@ pub fn UserSettingsPage() -> Html { }; - let current_password_state = { - let current_password = if let Ok(LoginState::Passwordless { password, .. }) = - LoginState::load_from_storage() - { - password.to_string() - } else { - String::new() - }; - - use_state(|| current_password) - }; - let new_password_state = use_state(String::new); - let new_password_repeat_state = use_state(String::new); let change_password = { + let current_password_state = { + let current_password = if let Ok(LoginState::Passwordless { password, .. }) = + LoginState::load_from_storage() + { + password.to_string() + } else { + String::new() + }; + + use_state(|| current_password) + }; + let new_password_state = use_state(String::new); + let new_password_repeat_state = use_state(String::new); let change_password_modal_id = "change-password".to_string(); let pw_err = use_state(|| None); let pw_error_obj = html! { @@ -169,6 +169,94 @@ pub fn UserSettingsPage() -> Html { } }; + let delete_account = { + const DELETE_MODAL_ID: &str = "delete-account-modal"; + let password_state = { + let current_password = if let Ok(LoginState::Passwordless { password, .. }) = + LoginState::load_from_storage() + { + password.to_string() + } else { + String::new() + }; + + use_state(|| current_password) + }; + let pw_err = use_state(|| None); + let pw_error_obj = html! { + + }; + let confirm_mode = { + let password_state = password_state.clone(); + let error_state = pw_err.clone(); + let token = token.clone(); + + DialogMode::ConfirmOrClose(Callback::from(move |_| { + pw_err.set(None); + + let Ok(password) = Password::new((*password_state).clone()) else { + error_state.set(Some(format!( + "password must be between {} and {} characters", + Password::MIN_LEN, + Password::MAX_LEN, + ))); + return; + }; + let on_success = { + Callback::from(move |_| { + if let Err(err) = LoginState::NoLogin.save_to_storage() { + log::error!("saving NoLogin storage state: {err}"); + } + Token::delete(); + if let Some(dialog) = gloo::utils::document() + .get_element_by_id(DELETE_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) + } + + if let Err(err) = gloo::utils::window().location().set_href("/") { + log::error!("setting href to [/]: {err:?}"); + } + }) + }; + + delete_account_request(token.clone(), password, pw_err.setter(), on_success) + })) + }; + html! { + + } + }; + html! { <> {error_obj} @@ -256,3 +345,68 @@ fn change_password_request( } }); } + +fn delete_account_request( + token: Token, + password: Password, + on_error: UseStateSetter>, + on_success: Callback<()>, +) { + let token = token.clone(); + let password = password.clone(); + let on_error = on_error.clone(); + let mut request = vec![]; + if let Err(err) = ciborium::into_writer(&DeleteUserRequest { password }, &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.delete_user).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 account deletion request: {err}"))); + return; + } + }; + let resp = match req.send().await { + Ok(resp) => resp, + Err(err) => { + on_error.set(Some(format!("sending account deletion 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() + ))); + } + } + }); +}