feat: classic ui account deletion

This commit is contained in:
2026-10-26 02:51:52 +01:00
parent 0beedfbf0a
commit 6aebad1f0f
4 changed files with 98 additions and 8 deletions

View File

@@ -236,6 +236,9 @@ pub async fn post_delete_user(
// An admin removing their own account ends their own session too.
Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(),
Ok(()) => axum::response::Redirect::to("/users").into_response(),
// Same as other forbidden classic actions: a plain 403, not a redirect
// to a page that would not show the error.
Err(e @ DomainError::Forbidden(_)) => crate::errors::domain_error_response(e),
Err(e) => axum::response::Redirect::to(&format!(
"/users/{target}?error={}",
crate::html::helpers::encode_domain_error(&e)

View File

@@ -103,10 +103,11 @@
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<p style="font-size:.85em;opacity:.7">This user and all their data will be permanently deleted. Enter your own password to confirm.</p>
<form method="POST" action="/users/{{ profile_user_id }}/delete">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<input type="password" name="password" placeholder="Your password" required autocomplete="current-password">
<button type="submit">Delete user</button>
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3)">Delete user</button>
</form>
</section>
{% endif %}

View File

@@ -69,7 +69,7 @@
<section style="margin-top:2rem;padding-top:1rem;border-top:1px solid rgba(255,255,255,0.1)">
<h3>Delete account</h3>
<p style="font-size:.85em;opacity:.7;margin-bottom:.5rem">
Permanently removes your account and everything on it: reviews, watchlist, goals, follows and uploaded images. This cannot be undone.
Deleting your account is permanent and cannot be undone. Your reviews, watchlist, goals and follows are removed, fediverse servers are notified, and your username can never be reused.
</p>
<form method="post" action="/settings/account/delete">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
@@ -77,7 +77,7 @@
Confirm with your password<br>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Delete my account</button>
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3)">Delete my account</button>
</form>
</section>
{% endblock %}

View File

@@ -206,11 +206,7 @@ async fn classic_admin_delete_redirects_to_the_member_list() {
let uri = format!("/users/{}/delete", bob.user_id);
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert!(
location(&headers).contains("error="),
"non-admin: {headers:?}"
);
assert_eq!(status, 403, "non-admin: {headers:?}");
promote(&pool, &alice.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
@@ -221,3 +217,93 @@ async fn classic_admin_delete_redirects_to_the_member_list() {
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn classic_admin_delete_requires_the_csrf_token() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let req = Request::builder()
.method(Method::POST)
.uri(format!("/users/{}/delete", bob.user_id))
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, &alice.cookie)
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
.unwrap();
let (status, _, _) = send(&router, req).await;
assert_eq!(status, 403);
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200, "target must survive a CSRF rejection");
}
#[tokio::test]
async fn a_non_admin_classic_delete_of_someone_else_is_forbidden() {
let (router, _pool, alice, bob) = two_users().await;
let uri = format!("/users/{}/delete", bob.user_id);
let (status, _, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 403);
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
assert_eq!(status, 200);
}
#[tokio::test]
async fn classic_admin_self_delete_logs_out_unless_last_admin() {
let (router, pool, alice, bob) = two_users().await;
promote(&pool, &alice.user_id).await;
let uri = format!("/users/{}/delete", alice.user_id);
// Sole admin: refused, redirected back with an error, still logged in.
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert!(location(&headers).contains("error="), "{headers:?}");
assert!(support::cookie_value(&headers, "token").is_none());
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 200);
// With a second admin the self-delete goes through and ends the session.
promote(&pool, &bob.user_id).await;
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
assert_eq!(status, 303);
assert_eq!(location(&headers), "/");
assert_eq!(
support::cookie_value(&headers, "token").as_deref(),
Some("")
);
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn delete_forms_render_only_where_they_apply() {
let (router, pool, alice, bob) = two_users().await;
let own_delete = "action=\"/settings/account/delete\"";
let user_delete = |id: &str| format!("action=\"/users/{id}/delete\"");
let (_, _, body) = send(&router, get("/settings/profile", Some(&alice.cookie))).await;
assert!(body.contains(own_delete), "owner settings page");
// A non-admin viewing someone else, and anonymous viewers, see no form.
let bob_page = format!("/users/{}", bob.user_id);
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
assert!(!body.contains(&user_delete(&bob.user_id)), "non-admin");
promote(&pool, &alice.user_id).await;
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
assert!(body.contains(&user_delete(&bob.user_id)), "admin on other");
let (_, _, body) = send(
&router,
get(&format!("/users/{}", alice.user_id), Some(&alice.cookie)),
)
.await;
assert!(
!body.contains(&user_delete(&alice.user_id)),
"admin on self"
);
let (_, _, body) = send(
&router,
get(&format!("{bob_page}?embed=true"), Some(&alice.cookie)),
)
.await;
assert!(!body.contains("/delete"), "embed page");
}