//! Profile updates, password, account deletion, stripe, email verification, appeals. use axum::{ extract::State, http::{header::HeaderMap, StatusCode}, response::{Html, IntoResponse, Response}, Form, Json, }; use serde::{Deserialize, Serialize}; use tower_sessions::Session; use crate::{ auth::AuthUser, db::{self, UserId, Username}, email, error::{AppError, Result, ResultExt}, helpers::is_htmx_request, templates::{AlertTemplate, FormStatusTemplate, SaveStatusTemplate}, validation, AppState, }; use super::SuccessMessageResponse; /// JSON response for profile updates. #[derive(Debug, Serialize)] struct ProfileResponse { id: UserId, username: Username, display_name: Option, bio: Option, } /// Form input for updating a user's display name and bio. #[derive(Debug, Deserialize)] pub struct UpdateProfileRequest { pub display_name: Option, pub bio: Option, } /// Update the authenticated user's display name and/or bio. #[tracing::instrument(skip_all, name = "users::update_profile")] pub(in crate::routes::api) async fn update_profile( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; // Validate input if let Some(ref name) = req.display_name { validation::validate_display_name(name)?; } if let Some(ref bio) = req.bio { validation::validate_bio(bio)?; } let updated = db::users::update_user_profile( &state.db, user.id, req.display_name.as_deref(), req.bio.as_deref(), ) .await?; if is_htmx_request(&headers) { return Ok(Html(SaveStatusTemplate { success: true, message: "Profile saved".to_string(), }.render_string()).into_response()); } Ok(Json(ProfileResponse { id: updated.id, username: updated.username, display_name: updated.display_name, bio: updated.bio, }).into_response()) } /// Form input for changing the user's password. #[derive(Debug, Deserialize)] pub struct UpdatePasswordRequest { pub current_password: String, pub new_password: String, } /// Change the authenticated user's password after verifying the current one. #[tracing::instrument(skip_all, name = "users::update_password")] pub(in crate::routes::api) async fn update_password( State(state): State, headers: HeaderMap, session: Session, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_sandbox()?; let is_htmx = is_htmx_request(&headers); // Get current user with password hash let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; // Verify current password if !crate::auth::verify_password(&req.current_password, &db_user.password_hash)? { if is_htmx { return Ok(Html(SaveStatusTemplate { success: false, message: "Current password is incorrect".to_string(), }.render_string()).into_response()); } return Err(AppError::BadRequest("Current password is incorrect".to_string())); } // Validate new password let password_len = req.new_password.chars().count(); if password_len < 8 { if is_htmx { return Ok(Html(SaveStatusTemplate { success: false, message: "New password must be at least 8 characters".to_string(), }.render_string()).into_response()); } return Err(AppError::validation( "New password must be at least 8 characters".to_string(), )); } if password_len > 128 { if is_htmx { return Ok(Html(SaveStatusTemplate { success: false, message: "Password must be 128 characters or fewer".to_string(), }.render_string()).into_response()); } return Err(AppError::validation( "Password must be 128 characters or fewer".to_string(), )); } // Check for breached password (advisory only, don't block) if let Some(count) = crate::auth::check_password_breach(&req.new_password).await { tracing::warn!(user_id = %user.id, event = "breached_password_change", breach_count = count, "User changed to breached password"); session.insert("password_warning", format!( "This password has appeared in {} known data breach(es). Consider changing it.", count )).await.ok(); } // Hash and update. NOTE: `update_user_password` bumps `users.jwt_invalidated_at` // in the SAME UPDATE as the password hash, which is what revokes outstanding // SyncKit/OAuth bearer tokens (the `SyncUser` extractor rejects any JWT whose // `iat <= jwt_invalidated_at`). The session sweep below only clears web session // ROWS — it is deliberately NOT the JWT-revocation mechanism. Do not "fix" this // by swapping in `delete_all_sessions_for_user`: that would also log the user // out of their current web session, and the JWT bump already happened here. let new_hash = crate::auth::hash_password(&req.new_password)?; db::users::update_user_password(&state.db, user.id, &new_hash).await?; // Invalidate all other sessions so stolen/leaked sessions can't survive a password change let current_tracking_id = session .get::(crate::auth::SESSION_TRACKING_KEY) .await .ok() .flatten(); if let Some(current_id) = current_tracking_id { let revoked_ids = db::sessions::delete_other_sessions(&state.db, current_id, user.id).await?; for id in &revoked_ids { state.session_cache.remove(id); } if !revoked_ids.is_empty() { tracing::info!(user_id = %user.id, revoked = revoked_ids.len(), event = "password_change_revoke_sessions", "Revoked other sessions on password change"); } } // Rotate session ID so old session cookie is invalidated session.cycle_id().await .context("session cycle")?; if is_htmx { return Ok(Html(SaveStatusTemplate { success: true, message: "Password updated".to_string(), }.render_string()).into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Permanently delete the authenticated user's account. /// If the creator has completed sales, content is kept accessible for 90 days /// so buyers can download their purchased files before removal. #[tracing::instrument(skip_all, name = "users::delete_account")] pub(in crate::routes::api) async fn delete_account( State(state): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_sandbox()?; if db::users::has_completed_sales(&state.db, user.id).await? { db::users::schedule_content_removal(&state.db, user.id).await?; tracing::info!(user_id = %user.id, "creator account deletion scheduled with 90-day content grace period"); // Notify historical buyers (capped + Postmark-throttled). Fire-and-forget. let pool = state.db.clone(); let email = state.email.clone(); let creator_name = user.display_name.clone() .unwrap_or_else(|| user.username.to_string()); let user_id = user.id; tokio::spawn(async move { crate::email::send_creator_departure_notifications(&pool, &email, user_id, creator_name).await; }); } else { db::users::delete_user(&state.db, user.id).await?; } Ok(StatusCode::NO_CONTENT) } /// Self-deactivate account (enter limbo state). #[tracing::instrument(skip_all, name = "users::deactivate_account")] pub(in crate::routes::api) async fn deactivate_account( State(state): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_sandbox()?; db::users::deactivate_user(&state.db, user.id).await?; tracing::info!(user_id = %user.id, "user self-deactivated account"); Ok(StatusCode::NO_CONTENT) } /// Reactivate a self-deactivated account. #[tracing::instrument(skip_all, name = "users::reactivate_account")] pub(in crate::routes::api) async fn reactivate_account( State(state): State, AuthUser(user): AuthUser, ) -> Result { db::users::reactivate_user(&state.db, user.id).await?; tracing::info!(user_id = %user.id, "user reactivated account"); Ok(StatusCode::NO_CONTENT) } /// Voluntarily pause creator account. Cancels the creator tier subscription, /// sets `cancel_at_period_end` on all active fan subscriptions (graceful expiry), /// and blocks new purchases. Content remains hosted indefinitely. #[tracing::instrument(skip_all, name = "users::pause_creator")] pub(in crate::routes::api) async fn pause_creator( State(state): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_sandbox()?; let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; if db_user.is_suspended() { return Err(AppError::BadRequest("Cannot pause a suspended account".to_string())); } if db_user.is_deactivated() { return Err(AppError::BadRequest("Cannot pause a deactivated account".to_string())); } if db_user.is_creator_paused() { return Err(AppError::BadRequest("Account is already paused".to_string())); } if !db_user.can_create_projects { return Err(AppError::BadRequest("Only creators can pause their account".to_string())); } if let Some(ref stripe) = state.stripe { // Cancel the creator's own tier subscription on Stripe (platform-level) if let Some(ct_sub) = db::creator_tiers::get_creator_sub_by_user(&state.db, user.id).await? && ct_sub.status == db::SubscriptionStatus::Active && let Err(e) = stripe.cancel_platform_subscription(&ct_sub.stripe_subscription_id).await { tracing::warn!(error = ?e, "failed to cancel creator tier subscription on Stripe during pause"); } // Set cancel_at_period_end on all active fan subscriptions (connected account) if let Some(ref stripe_account_id) = db_user.stripe_account_id { let fan_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, user.id).await?; for sub in &fan_subs { if let Err(e) = stripe.set_cancel_at_period_end( &sub.stripe_subscription_id, stripe_account_id, true, ).await { tracing::warn!( stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to set cancel_at_period_end on fan subscription during pause" ); } } } } // Set the pause timestamp db::users::pause_creator(&state.db, user.id).await?; tracing::info!(user_id = %user.id, "creator paused account"); Ok(StatusCode::NO_CONTENT) } /// Disconnect the authenticated user's Stripe account. #[tracing::instrument(skip_all, name = "users::disconnect_stripe")] pub(in crate::routes::api) async fn disconnect_stripe( State(state): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_suspended()?; db::users::disconnect_user_stripe(&state.db, user.id).await?; Ok(StatusCode::NO_CONTENT) } /// Resend the email verification link to the authenticated user. #[tracing::instrument(skip_all, name = "users::resend_verification")] pub(in crate::routes::api) async fn resend_verification( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let is_htmx = is_htmx_request(&headers); // Get full user data let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; // Check if already verified if db_user.email_verified { if is_htmx { return Ok(AlertTemplate::new("info", "Email already verified").into_response()); } return Ok(Json(SuccessMessageResponse { success: true, message: "Email already verified", }).into_response()); } // Generate verification URL let verify_url = email::generate_verification_url( &state.config.host_url, user.id, &db_user.email, &state.config.signing_secret, ); // Send verification email if let Err(e) = state.email .send_verification(&db_user.email, db_user.display_name.as_deref(), &verify_url) .await { if is_htmx { tracing::error!(error = ?e, "failed to send verification email"); return Ok(AlertTemplate::new("error", "Failed to send verification email. Please try again.").into_response()); } return Err(e); } tracing::info!(user_id = %user.id, "verification email sent"); if is_htmx { return Ok(AlertTemplate::new("success", "Verification email sent. Check your inbox.").into_response()); } Ok(Json(SuccessMessageResponse { success: true, message: "Verification email sent", }).into_response()) } /// Form input for requesting account deletion (requires username confirmation). #[derive(Debug, Deserialize)] pub struct RequestDeletionForm { pub username: String, } /// Send an account deletion confirmation email after verifying the username. #[tracing::instrument(skip_all, name = "users::request_account_deletion")] pub(in crate::routes::api) async fn request_account_deletion( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { user.check_not_sandbox()?; let is_htmx = is_htmx_request(&headers); // Get user from DB let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; // Verify username matches (case-insensitive) if form.username.to_lowercase() != db_user.username.to_lowercase() { if is_htmx { return Ok(Html(FormStatusTemplate { success: false, message: "Username does not match".to_string(), }.render_string()).into_response()); } return Err(AppError::BadRequest("Username does not match".to_string())); } // Generate deletion URL let delete_url = email::generate_deletion_url( &state.config.host_url, user.id, &db_user.email, &state.config.signing_secret, ); // Send deletion confirmation email if let Err(e) = state.email .send_deletion_confirmation(&db_user.email, db_user.display_name.as_deref(), &delete_url) .await { if is_htmx { tracing::error!(error = ?e, "failed to send deletion email"); return Ok(Html(FormStatusTemplate { success: false, message: "Failed to send email. Please try again.".to_string(), }.render_string()).into_response()); } return Err(e); } tracing::info!(user_id = %user.id, "deletion confirmation email sent"); if is_htmx { return Ok(Html(FormStatusTemplate { success: true, message: "Confirmation email sent. Check your inbox.".to_string(), }.render_string()).into_response()); } Ok(Json(SuccessMessageResponse { success: true, message: "Deletion confirmation email sent", }).into_response()) } /// Form input for submitting a suspension appeal. #[derive(Debug, Deserialize)] pub struct AppealForm { pub appeal_text: String, } /// Submit an appeal for a suspended account. #[tracing::instrument(skip_all, name = "users::submit_appeal")] pub(in crate::routes::api) async fn submit_appeal( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { let is_htmx = is_htmx_request(&headers); // Must be suspended to appeal if !user.suspended { if is_htmx { return Ok(AlertTemplate::new("info", "Your account is not suspended.").into_response()); } return Err(AppError::BadRequest("Account is not suspended".to_string())); } // Reject re-submission if a recent denial exists (within 30 days) let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; if db_user.appeal_decision.as_deref() == Some("denied") && let Some(decided_at) = db_user.appeal_decided_at { let days_since = (chrono::Utc::now() - decided_at).num_days(); if days_since < 30 { let msg = format!("Your appeal was denied. You may resubmit after {} days.", 30 - days_since); if is_htmx { return Ok(AlertTemplate::new("error", &msg).into_response()); } return Err(AppError::BadRequest(msg)); } } // Also reject if an appeal is already pending if db_user.appeal_submitted_at.is_some() && db_user.appeal_decision.is_none() { if is_htmx { return Ok(AlertTemplate::new("info", "You already have a pending appeal.").into_response()); } return Err(AppError::BadRequest("Appeal already pending".to_string())); } let appeal_text = form.appeal_text.trim(); if appeal_text.is_empty() || appeal_text.len() > 2000 { if is_htmx { return Ok(AlertTemplate::new("error", "Appeal must be between 1 and 2000 characters.").into_response()); } return Err(AppError::validation("Appeal must be between 1 and 2000 characters".to_string())); } db::users::submit_appeal(&state.db, user.id, appeal_text).await?; tracing::info!(user_id = %user.id, "suspension appeal submitted"); if is_htmx { return Ok(AlertTemplate::new("success", "Appeal submitted. We'll review it as soon as possible.").into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) }