use axum::{
extract::{Query, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse},
Json,
};
use serde::Deserialize;
use std::sync::Arc;
use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::models::MessageResponse;
use crate::repositories::{
default_expiry, generate_verification_token, hash_verification_token, normalize_email,
AuditEventType, TokenType,
};
use crate::services::{delete_account, EmailService};
use crate::utils::{authenticate, build_logout_cookies};
use crate::AppState;
const DELETE_CONFIRM_TEXT: &str = "DELETE";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestAccountDeletionRequest {
pub email: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmAccountDeletionRequest {
pub token: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteCurrentAccountRequest {
pub confirm_text: String,
}
#[derive(Debug, Deserialize)]
pub struct AccountDeletionPageQuery {
pub token: Option<String>,
}
pub async fn account_deletion_page<C: AuthCallback, E: EmailService>(
Query(query): Query<AccountDeletionPageQuery>,
) -> Html<String> {
Html(render_account_deletion_page(query.token.as_deref()))
}
pub async fn request_account_deletion<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(req): Json<RequestAccountDeletionRequest>,
) -> Result<(StatusCode, Json<MessageResponse>), AppError> {
let response = generic_request_response();
let email = normalize_email(&req.email);
let Some(user) = state.user_repo.find_by_email(&email).await? else {
return Ok(response);
};
if user.is_deleted() || user.email.is_none() {
return Ok(response);
}
queue_account_deletion_email(&state, &headers, &user).await?;
state
.audit_service
.log_user_event_or_warn(AuditEventType::AccountDeletionRequested, user.id, Some(&headers))
.await;
Ok(response)
}
pub async fn confirm_account_deletion<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(req): Json<ConfirmAccountDeletionRequest>,
) -> Result<(StatusCode, Json<MessageResponse>), AppError> {
let token_hash = hash_verification_token(&req.token);
let token = state
.verification_repo
.consume_if_valid(&token_hash)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to consume token: {}", e)))?
.ok_or_else(|| AppError::Validation("Invalid or expired deletion token".into()))?;
if token.token_type != TokenType::AccountDeletion {
return Err(AppError::Validation("Invalid deletion token".into()));
}
delete_account(&state, token.user_id, Some(&headers)).await?;
Ok((
StatusCode::OK,
Json(MessageResponse {
message: "Account deleted successfully".to_string(),
}),
))
}
pub async fn delete_current_account<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(req): Json<DeleteCurrentAccountRequest>,
) -> Result<impl IntoResponse, AppError> {
let auth = authenticate(&state, &headers).await?;
if auth.is_api_key_auth {
return Err(AppError::Forbidden(
"Account deletion requires an interactive session".into(),
));
}
if req.confirm_text.trim() != DELETE_CONFIRM_TEXT {
return Err(AppError::Validation(format!(
"Type {} to confirm account deletion",
DELETE_CONFIRM_TEXT
)));
}
delete_account(&state, auth.user_id, Some(&headers)).await?;
let message = MessageResponse {
message: "Account deleted successfully".to_string(),
};
if state.config.cookie.enabled {
let mut response = Json(message).into_response();
for cookie in build_logout_cookies(&state.config.cookie) {
if let Ok(value) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(header::SET_COOKIE, value);
}
}
Ok(response)
} else {
Ok(Json(message).into_response())
}
}
async fn queue_account_deletion_email<C: AuthCallback, E: EmailService>(
state: &Arc<AppState<C, E>>,
headers: &HeaderMap,
user: &crate::repositories::UserEntity,
) -> Result<(), AppError> {
let email = user
.email
.as_deref()
.ok_or_else(|| AppError::Validation("Account has no email address".into()))?;
state
.verification_repo
.delete_for_user(user.id, TokenType::AccountDeletion)
.await?;
let token = generate_verification_token();
let token_hash = hash_verification_token(&token);
state
.verification_repo
.create(
user.id,
&token_hash,
TokenType::AccountDeletion,
default_expiry(TokenType::AccountDeletion),
)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to create token: {}", e)))?;
let confirmation_base_url = build_account_deletion_page_url(state, headers);
state
.comms_service
.queue_account_deletion_email(
email,
user.name.as_deref(),
&token,
&confirmation_base_url,
user.id,
)
.await?;
Ok(())
}
fn generic_request_response() -> (StatusCode, Json<MessageResponse>) {
(
StatusCode::OK,
Json(MessageResponse {
message:
"If an account exists for that email, a deletion confirmation link has been sent"
.to_string(),
}),
)
}
fn build_account_deletion_page_url<C: AuthCallback, E: EmailService>(
state: &AppState<C, E>,
headers: &HeaderMap,
) -> String {
let default_scheme = state
.config
.server
.frontend_url
.as_deref()
.map(|url| if url.starts_with("https://") { "https" } else { "http" })
.unwrap_or("http");
let scheme = if state.config.server.trust_proxy {
headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.unwrap_or(default_scheme)
} else {
default_scheme
};
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get(header::HOST))
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{}:{}", state.config.server.host, state.config.server.port));
let base_path = state.config.server.auth_base_path.trim_end_matches('/');
let route = if base_path.is_empty() {
"/account-deletion".to_string()
} else {
format!("{base_path}/account-deletion")
};
format!("{scheme}://{host}{route}")
}
fn render_account_deletion_page(token: Option<&str>) -> String {
let body = if let Some(token) = token {
format!(
r#"
<h1>Confirm Account Deletion</h1>
<p>Confirm permanent deletion of your account. Financial and audit records required by law may be retained.</p>
<button id="confirm-button" type="button">Delete Account</button>
<p id="status"></p>
<script>
const button = document.getElementById('confirm-button');
const status = document.getElementById('status');
button.addEventListener('click', async () => {{
button.disabled = true;
status.textContent = 'Deleting account...';
const response = await fetch('./account-deletion/confirm', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ token: '{token}' }})
}});
const data = await response.json().catch(() => ({{ error: {{ message: 'Request failed' }} }}));
status.textContent = data.message || data.error?.message || 'Request failed';
if (!response.ok) {{
button.disabled = false;
}}
}});
</script>
"#,
token = escape_html(token)
)
} else {
r#"
<h1>Request Account Deletion</h1>
<p>Enter the email address on your account and we’ll send you a deletion confirmation link.</p>
<form id="request-form">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email" />
<button type="submit">Send Deletion Link</button>
</form>
<p id="status"></p>
<script>
const form = document.getElementById('request-form');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const email = document.getElementById('email').value;
status.textContent = 'Submitting request...';
const response = await fetch('./account-deletion/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await response.json().catch(() => ({ error: { message: 'Request failed' } }));
status.textContent = data.message || data.error?.message || 'Request failed';
});
</script>
"#
.to_string()
};
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Account Deletion</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: #f8fafc; color: #0f172a; }}
main {{ max-width: 560px; margin: 64px auto; padding: 32px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 18px; box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08); }}
h1 {{ margin-top: 0; font-size: 28px; }}
p {{ line-height: 1.6; }}
form {{ display: grid; gap: 12px; }}
input {{ font-size: 16px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 12px; }}
button {{ appearance: none; border: 0; border-radius: 12px; background: #b91c1c; color: white; padding: 12px 16px; font-size: 16px; font-weight: 600; cursor: pointer; }}
button:disabled {{ opacity: 0.6; cursor: not-allowed; }}
#status {{ min-height: 24px; color: #334155; }}
</style>
</head>
<body>
<main>{body}</main>
</body>
</html>"#,
body = body
)
}
fn escape_html(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}