account deletion

This commit is contained in:
emilis 2026-01-03 13:54:33 +00:00
parent 2d6b23522a
commit 97bc4da58b
No known key found for this signature in database
11 changed files with 258 additions and 35 deletions

7
Cargo.lock generated
View File

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

View File

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

View File

@ -21,4 +21,5 @@ paths! {
plans: "/s/plans",
check_token: "/s/tokens/check",
user_password: "/s/user/password",
delete_user: "/s/user/delete",
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 |_| {

View File

@ -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)
if id.as_str() == "username"
&& let Ok(ev) = MouseEvent::new("click")
{
log::error!("{err}");
}
return;
}
"password" => {}
_ => return,
}
if let Ok(ev) = MouseEvent::new("click") {
on_submit.emit(ev);
};
})

View File

@ -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,6 +36,7 @@ pub fn UserSettingsPage() -> Html {
<ErrorDisplay state={error_state.clone()}/>
};
let change_password = {
let current_password_state = {
let current_password = if let Ok(LoginState::Passwordless { password, .. }) =
LoginState::load_from_storage()
@ -49,7 +50,6 @@ pub fn UserSettingsPage() -> Html {
};
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! {
@ -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()
)));
}
}
});
}