//! Admin user management: listing, suspension, trust status. use axum::{ Form, extract::{Path, Query, State}, response::{IntoResponse, Response}, }; use serde::Deserialize; use sqlx::PgPool; use crate::{ AppCaches, Billing, Integrations, auth::AdminUser, background::BackgroundTx, db::{self, ModerationActionType, UserId}, email::EmailClient, error::{AppError, Result}, helpers::get_csrf_token, templates::{AdminUserEntriesTemplate, AdminUsersTemplate}, types::AdminUserRow, }; #[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(db): 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(&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, Some(f @ ("custom_pages" | "pages_locked")) => db::users::count_users(&db, Some(f)).await?, _ => 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(&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(db): 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(&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(&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(db): State, State(email): State, admin_user: 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(&db, id) .await? .ok_or(AppError::NotFound)?; // Record warning in moderation history db::moderation::create_action( &db, id, admin_user.admin_id(), ModerationActionType::Warning, reason, None, ) .await?; // Send warning email if let Err(e) = 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_user.id(), reason = %reason, "admin sent policy warning"); refresh_user_entries_partial(&db).await } /// Suspend a user account and send notification email. #[tracing::instrument(skip_all, name = "admin::admin_suspend_user")] #[allow(clippy::too_many_arguments)] pub(super) async fn admin_suspend_user( State(db): State, State(email): State, State(bg): State, State(caches): State, State(payments): State, State(integrations): State, admin_user: 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(&db, id) .await? .ok_or(AppError::NotFound)?; // Delegate to the shared moderation service so the web path and the // `mnw-admin` CLI perform the exact same due process (audit record, session // revocation, fan-sub pause, email). Web fans the Stripe calls out on the // background queue and evicts the in-memory session cache. super::moderation_service::suspend_creator( &db, &email, payments.stripe.as_ref(), super::moderation_service::FanoutMode::Background { bg: &bg, wam: integrations.wam.clone(), session_cache: &caches.session_cache, }, &db_user, admin_user.admin_id(), reason, ) .await?; refresh_user_entries_partial(&db).await } /// Unsuspend a user account (admin override). #[tracing::instrument(skip_all, name = "admin::admin_unsuspend_user")] pub(super) async fn admin_unsuspend_user( State(db): State, State(bg): State, State(caches): State, State(payments): State, State(integrations): 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(&db, id) .await? .ok_or(AppError::NotFound)?; super::moderation_service::unsuspend_creator( &db, payments.stripe.as_ref(), super::moderation_service::FanoutMode::Background { bg: &bg, wam: integrations.wam.clone(), session_cache: &caches.session_cache, }, &db_user, ) .await?; refresh_user_entries_partial(&db).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(db): State, State(email): State, State(bg): State, State(payments): State, State(integrations): State, admin_user: AdminUser, Path(id): Path, ) -> Result { let db_user = db::users::get_user_by_id(&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(&db, id).await?; // Record moderation action db::moderation::create_action( &db, id, admin_user.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) = payments.stripe && let Some(ref account_id) = db_user.stripe_account_id { let active_subs = db::subscriptions::get_active_subscriptions_by_creator(&db, id).await?; let paused_subs = db::subscriptions::get_paused_subscriptions_by_creator(&db, id).await?; let ids: Vec = active_subs .into_iter() .chain(paused_subs) .map(|s| s.stripe_subscription_id) .collect(); crate::payments::fan_ops::spawn_fan_sub_fanout( &bg, std::sync::Arc::clone(stripe), account_id.clone(), ids, crate::payments::fan_ops::FanSubOp::Cancel, integrations.wam.clone(), ); } // Send termination email let user_email = db_user.email.clone(); let user_name = db_user.display_name.clone(); let email = email.clone(); bg.spawn("account termination notification", async move { if let Err(e) = email .send_account_termination(&user_email, user_name.as_deref()) .await { tracing::error!(error = ?e, "failed to send account termination notification"); } }); tracing::info!( user_id = %id, admin_id = %admin_user.id(), "admin terminated user account (30-day export window started)" ); refresh_user_entries_partial(&db).await } /// Trust a user (uploads auto-publish). #[tracing::instrument(skip_all, name = "admin::admin_trust_user")] pub(super) async fn admin_trust_user( State(db): State, AdminUser(_admin): AdminUser, Path(id): Path, headers: axum::http::HeaderMap, ) -> Result { db::users::set_upload_trusted(&db, id, true).await?; tracing::info!(user_id = %id, "admin trusted user for uploads"); refresh_partial_for_target(&db, &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(db): State, AdminUser(_admin): AdminUser, Path(id): Path, headers: axum::http::HeaderMap, ) -> Result { db::users::set_upload_trusted(&db, id, false).await?; tracing::info!(user_id = %id, "admin untrusted user for uploads"); refresh_partial_for_target(&db, &headers).await } /// Lock a user's custom pages (moderation kill switch): the editor goes /// read-only and their custom profile/project pages render the platform /// default. The source is preserved, so unlocking restores it. Reversible. #[tracing::instrument(skip_all, name = "admin::admin_lock_custom_pages")] pub(super) async fn admin_lock_custom_pages( State(db): State, admin_user: AdminUser, Path(id): Path, headers: axum::http::HeaderMap, ) -> Result { db::users::set_custom_pages_locked(&db, id, true).await?; db::moderation::create_action( &db, id, admin_user.admin_id(), db::ModerationActionType::ContentRemoval, "custom pages locked", Some("custom-page"), ) .await?; tracing::info!(user_id = %id, "admin locked custom pages"); refresh_partial_for_target(&db, &headers).await } /// Unlock a user's custom pages, restoring their preserved custom source. #[tracing::instrument(skip_all, name = "admin::admin_unlock_custom_pages")] pub(super) async fn admin_unlock_custom_pages( State(db): State, AdminUser(_admin): AdminUser, Path(id): Path, headers: axum::http::HeaderMap, ) -> Result { db::users::set_custom_pages_locked(&db, id, false).await?; tracing::info!(user_id = %id, "admin unlocked custom pages"); refresh_partial_for_target(&db, &headers).await } /// Return the right partial based on which page triggered the request. async fn refresh_partial_for_target( db: &PgPool, 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(db).await?.into_response()) } else { super::uploads::refresh_held_uploads_partial(db).await } } /// Re-query users and return the entries partial (page 1, no filter). async fn refresh_user_entries_partial(db: &PgPool) -> Result { let per_page: i64 = 50; let total_count = db::users::count_users(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(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(), }) }