//! Admin user management: listing, suspension, trust status. use axum::{ extract::{Path, Query, State}, response::{IntoResponse, Response}, Form, }; use serde::Deserialize; use crate::{ auth::AdminUser, db::{self, ModerationActionType, UserId}, error::{AppError, Result}, helpers::{get_csrf_token, spawn_email}, templates::*, types::*, AppState, }; #[derive(Debug, Deserialize)] pub(super) struct UserFilterQuery { pub status: Option, pub page: Option, } /// Render the admin user management page. #[tracing::instrument(skip_all, name = "admin::admin_users")] pub(super) async fn admin_users( State(state): State, session: tower_sessions::Session, AdminUser(user): AdminUser, Query(query): Query, ) -> Result { let csrf_token = get_csrf_token(&session).await; let current_filter = query.status.clone().unwrap_or_default(); // Upper-clamp page so `OFFSET = (page-1)*per_page` doesn't overflow i64 // or produce a sqlx "value out of range" 500. 1e9 pages × 50 per_page is // already 50 billion rows — well past anything the admin panel will ever // reach, and keeps the OFFSET safely inside i64. let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000); let per_page: i64 = 50; let offset = (page - 1) * per_page; let (total_users_i64, total_suspended_i64) = db::users::count_users_summary(&state.db).await?; let total_users = total_users_i64 as usize; let total_suspended = total_suspended_i64 as usize; let total_count = match query.status.as_deref() { Some("suspended") => total_suspended_i64, Some("active") => total_users_i64 - total_suspended_i64, _ => total_users_i64, }; let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as i64; let db_users = db::users::get_all_users(&state.db, query.status.as_deref(), per_page, offset).await?; let users: Vec = db_users.iter().map(AdminUserRow::from_db).collect(); Ok(AdminUsersTemplate { csrf_token, session_user: Some(user), users, total_users, total_suspended, current_filter, current_page: page, total_pages, admin_active_page: "users", }) } /// Return filtered user entries as an HTMX partial. #[tracing::instrument(skip_all, name = "admin::admin_user_entries")] pub(super) async fn admin_user_entries( State(state): State, AdminUser(_user): AdminUser, Query(query): Query, ) -> Result { let current_filter = query.status.clone().unwrap_or_default(); // Upper-clamp page so `OFFSET = (page-1)*per_page` doesn't overflow i64 // or produce a sqlx "value out of range" 500. 1e9 pages × 50 per_page is // already 50 billion rows — well past anything the admin panel will ever // reach, and keeps the OFFSET safely inside i64. let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000); let per_page: i64 = 50; let offset = (page - 1) * per_page; let total_count = db::users::count_users(&state.db, query.status.as_deref()).await?; let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as i64; let db_users = db::users::get_all_users(&state.db, query.status.as_deref(), per_page, offset).await?; let users: Vec = db_users.iter().map(AdminUserRow::from_db).collect(); Ok(AdminUserEntriesTemplate { users, current_page: page, total_pages, current_filter, }) } #[derive(Debug, Deserialize)] pub(super) struct SuspendForm { pub reason: String, } /// Send a policy warning to a user without suspending their account. /// Records the warning in moderation history and emails the user. #[tracing::instrument(skip_all, name = "admin::admin_warn_user")] pub(super) async fn admin_warn_user( State(state): State, AdminUser(admin): AdminUser, Path(id): Path, Form(form): Form, ) -> Result { let reason = form.reason.trim(); if reason.is_empty() { return Err(AppError::validation("Reason is required".to_string())); } let db_user = db::users::get_user_by_id(&state.db, id) .await? .ok_or(AppError::NotFound)?; // Record warning in moderation history db::moderation::create_action(&state.db, id, admin.id, ModerationActionType::Warning, reason, None).await?; // Send warning email if let Err(e) = state.email .send_policy_warning(&db_user.email, db_user.display_name.as_deref(), reason) .await { tracing::error!(error = ?e, user_id = %id, "failed to send warning email"); } tracing::info!(user_id = %id, admin_id = %admin.id, reason = %reason, "admin sent policy warning"); refresh_user_entries_partial(&state).await } /// Suspend a user account and send notification email. #[tracing::instrument(skip_all, name = "admin::admin_suspend_user")] pub(super) async fn admin_suspend_user( State(state): State, AdminUser(admin): AdminUser, Path(id): Path, Form(form): Form, ) -> Result { let reason = form.reason.trim(); if reason.is_empty() { return Err(AppError::validation("Reason is required".to_string())); } // Get user for email notification let db_user = db::users::get_user_by_id(&state.db, id) .await? .ok_or(AppError::NotFound)?; db::users::suspend_user(&state.db, id, reason).await?; // Immediately revoke all sessions and evict from cache so the suspended // user cannot act during the 30-second session-touch cache window. let revoked_ids = db::sessions::delete_all_sessions_for_user(&state.db, id).await?; for sid in &revoked_ids { state.session_cache.remove(sid); } if !revoked_ids.is_empty() { tracing::info!(user_id = %id, revoked = revoked_ids.len(), "revoked sessions on suspension"); } // Record moderation action for audit trail db::moderation::create_action(&state.db, id, admin.id, ModerationActionType::Suspension, reason, None).await?; // Pause fan subscriptions to this creator's projects if let Some(ref stripe) = state.stripe && let Some(ref account_id) = db_user.stripe_account_id { let subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, id).await?; let count = subs.len(); for sub in &subs { if let Err(e) = stripe.pause_subscription(&sub.stripe_subscription_id, account_id).await { tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to pause subscription on Stripe"); } } let paused = db::subscriptions::pause_subscriptions_for_creator(&state.db, id).await?; tracing::info!(user_id = %id, stripe_paused = count, db_paused = paused, "paused fan subscriptions for suspended creator"); } // Send notification email (fire-and-forget) if let Err(e) = state.email .send_suspension_notification(&db_user.email, db_user.display_name.as_deref(), reason) .await { tracing::error!(error = ?e, user_id = %id, "failed to send suspension email"); } tracing::info!(user_id = %id, reason = %reason, "admin suspended user"); refresh_user_entries_partial(&state).await } /// Unsuspend a user account (admin override). #[tracing::instrument(skip_all, name = "admin::admin_unsuspend_user")] pub(super) async fn admin_unsuspend_user( State(state): State, AdminUser(_admin): AdminUser, Path(id): Path, ) -> Result { // Get user for Stripe account ID before unsuspending let db_user = db::users::get_user_by_id(&state.db, id) .await? .ok_or(AppError::NotFound)?; db::users::unsuspend_user(&state.db, id).await?; // Resolve suspension action in moderation history db::moderation::resolve_actions_by_type(&state.db, id, ModerationActionType::Suspension).await?; // Resume paused fan subscriptions if let Some(ref stripe) = state.stripe && let Some(ref account_id) = db_user.stripe_account_id { let resumed = db::subscriptions::resume_subscriptions_for_creator(&state.db, id).await?; for sub in &resumed { if let Err(e) = stripe.resume_subscription(&sub.stripe_subscription_id, account_id).await { tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to resume subscription on Stripe"); } } tracing::info!(user_id = %id, resumed = resumed.len(), "resumed fan subscriptions for unsuspended creator"); } tracing::info!(user_id = %id, "admin unsuspended user"); refresh_user_entries_partial(&state).await } /// Permanently terminate a user account (enforcement ladder step 4). /// /// The account must already be suspended. Sets `terminated_at`, hides all items, /// cancels subscriptions, and emails the user. The user has 30 days to export /// data before the scheduler deletes the account. #[tracing::instrument(skip_all, name = "admin::admin_terminate_user")] pub(super) async fn admin_terminate_user( State(state): State, AdminUser(admin): AdminUser, Path(id): Path, ) -> Result { let db_user = db::users::get_user_by_id(&state.db, id) .await? .ok_or(AppError::NotFound)?; if !db_user.is_suspended() { return Err(AppError::validation( "Account must be suspended before termination".to_string(), )); } if db_user.terminated_at.is_some() { return Err(AppError::validation( "Account is already terminated".to_string(), )); } db::users::terminate_user(&state.db, id).await?; // Record moderation action db::moderation::create_action( &state.db, id, admin.id, ModerationActionType::Termination, db_user.suspension_reason.as_deref().unwrap_or("Account terminated"), None, ).await?; // Cancel all fan subscriptions — both active and paused (suspension already paused them) if let Some(ref stripe) = state.stripe && let Some(ref account_id) = db_user.stripe_account_id { let active_subs = db::subscriptions::get_active_subscriptions_by_creator(&state.db, id).await?; let paused_subs = db::subscriptions::get_paused_subscriptions_by_creator(&state.db, id).await?; for sub in active_subs.iter().chain(paused_subs.iter()) { if let Err(e) = stripe.cancel_subscription(&sub.stripe_subscription_id, account_id).await { tracing::error!(stripe_sub_id = %sub.stripe_subscription_id, error = ?e, "failed to cancel subscription on termination"); } } } // Send termination email let user_email = db_user.email.clone(); let user_name = db_user.display_name.clone(); spawn_email!(state, "account termination notification", |email| { email.send_account_termination(&user_email, user_name.as_deref()) }); tracing::info!( user_id = %id, admin_id = %admin.id, "admin terminated user account (30-day export window started)" ); refresh_user_entries_partial(&state).await } /// Trust a user (uploads auto-publish). #[tracing::instrument(skip_all, name = "admin::admin_trust_user")] pub(super) async fn admin_trust_user( State(state): State, AdminUser(_admin): AdminUser, Path(id): Path, headers: axum::http::HeaderMap, ) -> Result { db::users::set_upload_trusted(&state.db, id, true).await?; tracing::info!(user_id = %id, "admin trusted user for uploads"); refresh_partial_for_target(&state, &headers).await } /// Untrust a user (uploads require review). #[tracing::instrument(skip_all, name = "admin::admin_untrust_user")] pub(super) async fn admin_untrust_user( State(state): State, AdminUser(_admin): AdminUser, Path(id): Path, headers: axum::http::HeaderMap, ) -> Result { db::users::set_upload_trusted(&state.db, id, false).await?; tracing::info!(user_id = %id, "admin untrusted user for uploads"); refresh_partial_for_target(&state, &headers).await } /// Return the right partial based on which page triggered the request. async fn refresh_partial_for_target(state: &AppState, headers: &axum::http::HeaderMap) -> Result { let target = headers.get("HX-Target").and_then(|v| v.to_str().ok()).unwrap_or(""); if target == "users-table" { Ok(refresh_user_entries_partial(state).await?.into_response()) } else { super::uploads::refresh_held_uploads_partial(state).await } } /// Re-query users and return the entries partial (page 1, no filter). async fn refresh_user_entries_partial(state: &AppState) -> Result { let per_page: i64 = 50; let total_count = db::users::count_users(&state.db, None).await?; let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as i64; let db_users = db::users::get_all_users(&state.db, None, per_page, 0).await?; let users: Vec = db_users.iter().map(AdminUserRow::from_db).collect(); Ok(AdminUserEntriesTemplate { users, current_page: 1, total_pages, current_filter: String::new(), }) }