summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/users.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers/users.rs')
-rw-r--r--makima/src/server/handlers/users.rs972
1 files changed, 972 insertions, 0 deletions
diff --git a/makima/src/server/handlers/users.rs b/makima/src/server/handlers/users.rs
new file mode 100644
index 0000000..0b2ccdd
--- /dev/null
+++ b/makima/src/server/handlers/users.rs
@@ -0,0 +1,972 @@
+//! HTTP handlers for user account management.
+//!
+//! These endpoints allow users to manage their account settings:
+//! - Change password
+//! - Change email
+//! - Delete account
+
+use axum::{
+ extract::State,
+ http::{HeaderMap, StatusCode},
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+use crate::server::auth::UserOnly;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+// =============================================================================
+// Request/Response Types
+// =============================================================================
+
+/// Request to change password.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangePasswordRequest {
+ /// The user's current password for verification
+ pub current_password: String,
+ /// The new password to set
+ pub new_password: String,
+}
+
+/// Response after changing password.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangePasswordResponse {
+ pub success: bool,
+ pub message: String,
+}
+
+/// Request to change email.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangeEmailRequest {
+ /// The user's password for verification
+ pub password: String,
+ /// The new email address to set
+ pub new_email: String,
+}
+
+/// Response after changing email.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangeEmailResponse {
+ pub success: bool,
+ pub message: String,
+ /// Whether a verification email was sent to the new address
+ pub verification_sent: bool,
+}
+
+/// Request to delete account.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DeleteAccountRequest {
+ /// The user's password for verification
+ pub password: String,
+ /// Confirmation text - must match the user's email
+ pub confirmation: String,
+}
+
+/// Response after deleting account.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DeleteAccountResponse {
+ pub success: bool,
+ pub message: String,
+}
+
+// =============================================================================
+// Password Validation
+// =============================================================================
+
+/// Password strength validation result.
+#[derive(Debug)]
+pub struct PasswordValidation {
+ pub is_valid: bool,
+ pub errors: Vec<String>,
+}
+
+/// Validate password strength.
+/// Requirements:
+/// - At least 6 characters (matches login form)
+fn validate_password_strength(password: &str) -> PasswordValidation {
+ let mut errors = Vec::new();
+
+ if password.len() < 6 {
+ errors.push("Password must be at least 6 characters long".to_string());
+ }
+
+ PasswordValidation {
+ is_valid: errors.is_empty(),
+ errors,
+ }
+}
+
+/// Validate email format.
+fn validate_email(email: &str) -> bool {
+ // Basic email validation - must contain @ and at least one . after @
+ let parts: Vec<&str> = email.split('@').collect();
+ if parts.len() != 2 {
+ return false;
+ }
+ let local = parts[0];
+ let domain = parts[1];
+ // Local part must not be empty
+ if local.is_empty() {
+ return false;
+ }
+ // Domain must have at least one dot and not start/end with dot
+ domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
+}
+
+// =============================================================================
+// Supabase Admin Client
+// =============================================================================
+
+/// Supabase Admin API client for user management operations.
+/// Uses the service role key for admin-level operations.
+pub struct SupabaseAdminClient {
+ base_url: String,
+ secret_api_key: String,
+ client: reqwest::Client,
+}
+
+impl SupabaseAdminClient {
+ /// Create a new Supabase admin client from environment variables.
+ pub fn from_env() -> Option<Self> {
+ let base_url = std::env::var("SUPABASE_URL").ok()?;
+ let secret_api_key = std::env::var("SUPABASE_SECRET_API_KEY").ok()?;
+
+ Some(Self {
+ base_url,
+ secret_api_key,
+ client: reqwest::Client::new(),
+ })
+ }
+
+ /// Verify a user's password by attempting to sign in.
+ pub async fn verify_password(&self, email: &str, password: &str) -> Result<bool, String> {
+ let url = format!("{}/auth/v1/token?grant_type=password", self.base_url);
+
+ let response = self
+ .client
+ .post(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to verify password: {}", e))?;
+
+ Ok(response.status().is_success())
+ }
+
+ /// Update a user's password.
+ pub async fn update_password(
+ &self,
+ user_id: &str,
+ new_password: &str,
+ ) -> Result<(), String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "password": new_password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update password: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update password: {}", error_text))
+ }
+ }
+
+ /// Update a user's email.
+ pub async fn update_email(
+ &self,
+ user_id: &str,
+ new_email: &str,
+ ) -> Result<(), String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": new_email,
+ "email_confirm": true
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update email: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update email: {}", error_text))
+ }
+ }
+
+ /// Delete a user from Supabase Auth.
+ pub async fn delete_user(&self, user_id: &str) -> Result<(), String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .delete(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to delete user: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to delete user: {}", error_text))
+ }
+ }
+
+ /// Get user info including email.
+ pub async fn get_user(&self, user_id: &str) -> Result<Option<String>, String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .get(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to get user: {}", e))?;
+
+ if response.status().is_success() {
+ let json: serde_json::Value = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse user data: {}", e))?;
+ Ok(json.get("email").and_then(|e| e.as_str()).map(String::from))
+ } else if response.status() == reqwest::StatusCode::NOT_FOUND {
+ Ok(None)
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to get user: {}", error_text))
+ }
+ }
+}
+
+// =============================================================================
+// Supabase User Client (uses user's JWT, no admin required)
+// =============================================================================
+
+/// Supabase User API client for self-service operations.
+/// Uses the user's JWT token - no admin/service role key required.
+pub struct SupabaseUserClient {
+ base_url: String,
+ anon_key: String,
+ jwt_token: String,
+ client: reqwest::Client,
+}
+
+impl SupabaseUserClient {
+ /// Create a new Supabase user client from environment and JWT token.
+ pub fn new(jwt_token: String) -> Option<Self> {
+ let base_url = std::env::var("SUPABASE_URL").ok()?;
+ let anon_key = std::env::var("SUPABASE_ANON_KEY").ok()?;
+
+ Some(Self {
+ base_url,
+ anon_key,
+ jwt_token,
+ client: reqwest::Client::new(),
+ })
+ }
+
+ /// Update the user's password using their own JWT.
+ pub async fn update_password(&self, new_password: &str) -> Result<(), String> {
+ let url = format!("{}/auth/v1/user", self.base_url);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.anon_key)
+ .header("Authorization", format!("Bearer {}", self.jwt_token))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "password": new_password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update password: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update password: {}", error_text))
+ }
+ }
+
+ /// Update the user's email using their own JWT.
+ pub async fn update_email(&self, new_email: &str) -> Result<(), String> {
+ let url = format!("{}/auth/v1/user", self.base_url);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.anon_key)
+ .header("Authorization", format!("Bearer {}", self.jwt_token))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": new_email
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update email: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update email: {}", error_text))
+ }
+ }
+
+ /// Verify current password by attempting to sign in.
+ pub async fn verify_password(&self, email: &str, password: &str) -> Result<bool, String> {
+ let url = format!("{}/auth/v1/token?grant_type=password", self.base_url);
+
+ let response = self
+ .client
+ .post(&url)
+ .header("apikey", &self.anon_key)
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to verify password: {}", e))?;
+
+ Ok(response.status().is_success())
+ }
+}
+
+// =============================================================================
+// Handlers
+// =============================================================================
+
+/// Change the authenticated user's password.
+///
+/// Requires verification of the current password before allowing the change.
+/// The new password must meet strength requirements.
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/me/password",
+ request_body = ChangePasswordRequest,
+ responses(
+ (status = 200, description = "Password changed successfully", body = ChangePasswordResponse),
+ (status = 400, description = "Invalid request (weak password, wrong current password)", body = ApiError),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 503, description = "Supabase not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn change_password_handler(
+ State(_state): State<SharedState>,
+ headers: HeaderMap,
+ UserOnly(user): UserOnly,
+ Json(req): Json<ChangePasswordRequest>,
+) -> impl IntoResponse {
+ // Validate new password strength
+ let validation = validate_password_strength(&req.new_password);
+ if !validation.is_valid {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "WEAK_PASSWORD",
+ &validation.errors.join("; "),
+ )),
+ )
+ .into_response();
+ }
+
+ // Get user's email (required for password verification)
+ let email = match &user.email {
+ Some(email) => email.clone(),
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("EMAIL_REQUIRED", "User email not available")),
+ )
+ .into_response();
+ }
+ };
+
+ // Extract JWT from Authorization header for user-level API calls
+ let jwt_token = headers
+ .get("Authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.strip_prefix("Bearer "))
+ .map(|s| s.to_string());
+
+ // Try user client first (uses JWT, no admin required), fall back to admin client
+ if let Some(token) = jwt_token {
+ if let Some(user_client) = SupabaseUserClient::new(token) {
+ // Verify current password
+ match user_client.verify_password(&email, &req.current_password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Current password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update password using user's JWT
+ return match user_client.update_password(&req.new_password).await {
+ Ok(()) => {
+ tracing::info!("Password changed for user {}", user.user_id);
+ Json(ChangePasswordResponse {
+ success: true,
+ message: "Password changed successfully".to_string(),
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to update password: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update password")),
+ )
+ .into_response()
+ }
+ };
+ }
+ }
+
+ // Fall back to admin client if user client not available
+ let admin_client = match SupabaseAdminClient::from_env() {
+ Some(client) => client,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "SUPABASE_NOT_CONFIGURED",
+ "Supabase not configured. Ensure SUPABASE_URL and SUPABASE_ANON_KEY are set.",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify current password
+ match admin_client.verify_password(&email, &req.current_password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Current password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update password in Supabase
+ match admin_client
+ .update_password(&user.user_id.to_string(), &req.new_password)
+ .await
+ {
+ Ok(()) => {
+ tracing::info!("Password changed for user {}", user.user_id);
+ Json(ChangePasswordResponse {
+ success: true,
+ message: "Password changed successfully".to_string(),
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to update password: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update password")),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Change the authenticated user's email address.
+///
+/// Requires password verification before allowing the change.
+/// The new email will be updated directly (Supabase handles verification if configured).
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/me/email",
+ request_body = ChangeEmailRequest,
+ responses(
+ (status = 200, description = "Email changed successfully", body = ChangeEmailResponse),
+ (status = 400, description = "Invalid request (invalid email, wrong password)", body = ApiError),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 503, description = "Supabase not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn change_email_handler(
+ State(state): State<SharedState>,
+ headers: HeaderMap,
+ UserOnly(user): UserOnly,
+ Json(req): Json<ChangeEmailRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Validate new email format
+ if !validate_email(&req.new_email) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_EMAIL", "Invalid email format")),
+ )
+ .into_response();
+ }
+
+ // Get user's current email (required for password verification)
+ let current_email = match &user.email {
+ Some(email) => email.clone(),
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("EMAIL_REQUIRED", "User email not available")),
+ )
+ .into_response();
+ }
+ };
+
+ // Extract JWT from Authorization header for user-level API calls
+ let jwt_token = headers
+ .get("Authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.strip_prefix("Bearer "))
+ .map(|s| s.to_string());
+
+ // Try user client first (uses JWT, no admin required), fall back to admin client
+ if let Some(token) = jwt_token {
+ if let Some(user_client) = SupabaseUserClient::new(token) {
+ // Verify password
+ match user_client.verify_password(&current_email, &req.password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update email using user's JWT
+ if let Err(e) = user_client.update_email(&req.new_email).await {
+ tracing::error!("Failed to update email in Supabase: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update email")),
+ )
+ .into_response();
+ }
+
+ // Update email in our database
+ if let Err(e) = sqlx::query("UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2")
+ .bind(&req.new_email)
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::error!("Failed to update email in database: {}", e);
+ }
+
+ tracing::info!(
+ "Email changed for user {} from {} to {}",
+ user.user_id,
+ current_email,
+ req.new_email
+ );
+
+ return Json(ChangeEmailResponse {
+ success: true,
+ message: "Email changed successfully".to_string(),
+ verification_sent: false,
+ })
+ .into_response();
+ }
+ }
+
+ // Fall back to admin client if user client not available
+ let admin_client = match SupabaseAdminClient::from_env() {
+ Some(client) => client,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "SUPABASE_NOT_CONFIGURED",
+ "Supabase not configured. Ensure SUPABASE_URL and SUPABASE_ANON_KEY are set.",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify password
+ match admin_client.verify_password(&current_email, &req.password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update email in Supabase
+ if let Err(e) = admin_client
+ .update_email(&user.user_id.to_string(), &req.new_email)
+ .await
+ {
+ tracing::error!("Failed to update email in Supabase: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update email")),
+ )
+ .into_response();
+ }
+
+ // Update email in our database
+ if let Err(e) = sqlx::query("UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2")
+ .bind(&req.new_email)
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::error!("Failed to update email in database: {}", e);
+ }
+
+ tracing::info!(
+ "Email changed for user {} from {} to {}",
+ user.user_id,
+ current_email,
+ req.new_email
+ );
+
+ Json(ChangeEmailResponse {
+ success: true,
+ message: "Email changed successfully".to_string(),
+ verification_sent: false,
+ })
+ .into_response()
+}
+
+/// Delete the authenticated user's account.
+///
+/// This permanently deletes:
+/// - The user's Supabase Auth account
+/// - The user's record in our database
+/// - All associated data (API keys, files, tasks, etc. via CASCADE)
+///
+/// Requires password verification and confirmation text matching the user's email.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/users/me",
+ request_body = DeleteAccountRequest,
+ responses(
+ (status = 200, description = "Account deleted successfully", body = DeleteAccountResponse),
+ (status = 400, description = "Invalid request (wrong password, wrong confirmation)", body = ApiError),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 503, description = "Supabase not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn delete_account_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+ Json(req): Json<DeleteAccountRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get Supabase admin client - required for full account deletion
+ let admin_client = match SupabaseAdminClient::from_env() {
+ Some(client) => client,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "SUPABASE_ADMIN_NOT_CONFIGURED",
+ "Account deletion requires SUPABASE_SECRET_API_KEY to be configured",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify confirmation is "DELETE MY ACCOUNT"
+ const REQUIRED_CONFIRMATION: &str = "DELETE MY ACCOUNT";
+ if req.confirmation != REQUIRED_CONFIRMATION {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "INVALID_CONFIRMATION",
+ format!("Confirmation text must be exactly: {}", REQUIRED_CONFIRMATION),
+ )),
+ )
+ .into_response();
+ }
+
+ // Get user's email (required for password verification)
+ let email = match &user.email {
+ Some(e) => e.clone(),
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("EMAIL_REQUIRED", "User email not available")),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify password
+ match admin_client.verify_password(&email, &req.password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Delete from our database first (this will cascade to related records)
+ // Get the owner_id before deleting
+ let owner_id = user.owner_id;
+
+ // Delete API keys for this user (explicit deletion for audit purposes)
+ if let Err(e) = sqlx::query("UPDATE api_keys SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL")
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to revoke API keys during account deletion: {}", e);
+ }
+
+ // Delete user record
+ if let Err(e) = sqlx::query("DELETE FROM users WHERE id = $1")
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::error!("Failed to delete user from database: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to delete account")),
+ )
+ .into_response();
+ }
+
+ // Delete files owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM files WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete user files: {}", e);
+ }
+
+ // Delete tasks owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM tasks WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete user tasks: {}", e);
+ }
+
+ // Delete mesh chat conversations owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM mesh_chat_conversations WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete mesh chat conversations: {}", e);
+ }
+
+ // Delete daemons owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM daemons WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete user daemons: {}", e);
+ }
+
+ // Delete owner record
+ if let Err(e) = sqlx::query("DELETE FROM owners WHERE id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete owner record: {}", e);
+ }
+
+ // Delete from Supabase Auth
+ if let Err(e) = admin_client.delete_user(&user.user_id.to_string()).await {
+ tracing::error!("Failed to delete user from Supabase Auth: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new(
+ "SUPABASE_DELETE_FAILED",
+ "Failed to delete user from authentication system",
+ )),
+ )
+ .into_response();
+ }
+
+ tracing::info!("Account deleted for user {} ({})", user.user_id, email);
+
+ Json(DeleteAccountResponse {
+ success: true,
+ message: "Account deleted successfully".to_string(),
+ })
+ .into_response()
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_password_validation_success() {
+ // Minimum 6 characters
+ let result = validate_password_strength("abcdef");
+ assert!(result.is_valid);
+ assert!(result.errors.is_empty());
+
+ let result = validate_password_strength("Password123");
+ assert!(result.is_valid);
+ assert!(result.errors.is_empty());
+ }
+
+ #[test]
+ fn test_password_validation_too_short() {
+ let result = validate_password_strength("12345");
+ assert!(!result.is_valid);
+ assert!(result.errors.iter().any(|e| e.contains("6 characters")));
+ }
+
+ #[test]
+ fn test_email_validation_valid() {
+ assert!(validate_email("user@example.com"));
+ assert!(validate_email("user.name@example.co.uk"));
+ assert!(validate_email("user+tag@example.org"));
+ }
+
+ #[test]
+ fn test_email_validation_invalid() {
+ assert!(!validate_email("userexample.com"));
+ assert!(!validate_email("user@"));
+ assert!(!validate_email("@example.com"));
+ assert!(!validate_email("user@.com"));
+ assert!(!validate_email("user@example."));
+ }
+}