account deletion
This commit is contained in:
parent
2d6b23522a
commit
97bc4da58b
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -21,4 +21,5 @@ paths! {
|
|||
plans: "/s/plans",
|
||||
check_token: "/s/tokens/check",
|
||||
user_password: "/s/user/password",
|
||||
delete_user: "/s/user/delete",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<User> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,
|
||||
Cbor(DeleteUserRequest { password }): Cbor<DeleteUserRequest>,
|
||||
) -> Result<impl IntoResponse, ServerError> {
|
||||
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<AppState>,
|
||||
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |_| {
|
||||
|
|
|
|||
|
|
@ -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::<HtmlElement>().ok())
|
||||
&& let Err(err) = password.focus().map_err(Into::<JsError>::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);
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
<ErrorDisplay state={error_state.clone()}/>
|
||||
};
|
||||
|
||||
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! {
|
||||
<ErrorDisplay state={pw_err.clone()} closable=false/>
|
||||
};
|
||||
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::<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)
|
||||
}
|
||||
|
||||
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! {
|
||||
<DialogModal
|
||||
id={DELETE_MODAL_ID.to_string()}
|
||||
button_content={html!{
|
||||
<span class="delete-account">{"delete account"}</span>
|
||||
}}
|
||||
mode={confirm_mode}
|
||||
close_backdrop=true
|
||||
>
|
||||
{pw_error_obj}
|
||||
<p class="delete-account-info">
|
||||
{"this action will delete your account and all data associated "}
|
||||
{"with it, including any plan tiles you've clicked on in other "}
|
||||
{"people's plans"}
|
||||
</p>
|
||||
<label for="current-password">{"current password"}</label>
|
||||
<StateInput
|
||||
id={Some(String::from("current-password"))}
|
||||
state={password_state.clone()}
|
||||
input_type={InputType::Password}
|
||||
/>
|
||||
|
||||
</DialogModal>
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div class="user-settings">
|
||||
|
|
@ -183,6 +271,7 @@ pub fn UserSettingsPage() -> Html {
|
|||
<h1>{token.username.to_string()}</h1>
|
||||
<div class="user-options">
|
||||
{change_password}
|
||||
{delete_account}
|
||||
</div>
|
||||
</div>
|
||||
{error_obj}
|
||||
|
|
@ -256,3 +345,68 @@ fn change_password_request(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn delete_account_request(
|
||||
token: Token,
|
||||
password: Password,
|
||||
on_error: UseStateSetter<Option<String>>,
|
||||
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::<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