//! User-level dashboard tab handlers. mod creator; mod integrations; mod payments; pub(in crate::routes::pages::dashboard) use creator::{ dashboard_tab_analytics, dashboard_tab_creator, }; pub(in crate::routes::pages::dashboard) use integrations::{ dashboard_tab_forums, dashboard_tab_media, dashboard_tab_synckit, }; pub(in crate::routes::pages::dashboard) use payments::{ dashboard_tab_contacts, dashboard_tab_payments, dashboard_transactions, }; use axum::extract::State; use axum::http::HeaderMap; use axum::response::IntoResponse; use tower_sessions::Session; use crate::{ auth::{AuthUser, SESSION_TRACKING_KEY}, db, error::{AppError, Result}, helpers, templates::*, types::*, AppState, }; /// Render the HTMX partial for the dashboard settings meta-tab. /// Includes profile content inline; other sections loaded via HTMX sub-nav. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_settings")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings( State(state): State, AuthUser(session_user): AuthUser, ) -> Result { let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_links = db::custom_links::get_custom_links_by_user(&state.db, session_user.id).await?; let user = User::from(&db_user); let custom_links: Vec = db_links .into_iter() .map(|l| CustomLinkWithId { id: l.id.to_string(), url: l.url, title: l.title, }) .collect(); let feed_url = helpers::generate_feed_url( &state.config.host_url, session_user.id, db_user.feed_key_version, &state.config.signing_secret, ); let custom_domain = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id) .await? .map(|d| { let instructions = if d.verified { String::new() } else { format!( "Point {0} at connect.makenot.work (CNAME, DNS-only) and add a TXT _mnw-verify.{0} with value {1}, then verify.", d.domain, d.verification_token ) }; crate::templates::CustomDomainInfo { id: d.id.to_string(), domain: d.domain, verified: d.verified, verification_token: d.verification_token, instructions, } }); let has_media = session_user.can_create_projects; let git_enabled = state.config.git_repos_path.is_some(); let has_mt_memberships = state.config.mt_base_url.is_some(); Ok(UserSettingsTabTemplate { user, custom_links, feed_url, can_create_projects: session_user.can_create_projects, custom_domain, has_media, git_enabled, has_mt_memberships, }) } /// Legacy route; redirects to the profile tab. pub(in crate::routes::pages::dashboard) async fn dashboard_tab_details( state: State, session_user: AuthUser, ) -> Result { dashboard_tab_profile(state, session_user).await } /// Render the HTMX partial for the dashboard profile tab. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_profile")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_profile( State(state): State, AuthUser(session_user): AuthUser, ) -> Result { let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_links = db::custom_links::get_custom_links_by_user(&state.db, session_user.id).await?; let user = User::from(&db_user); let custom_links: Vec = db_links .into_iter() .map(|l| CustomLinkWithId { id: l.id.to_string(), url: l.url, title: l.title, }) .collect(); let feed_url = helpers::generate_feed_url( &state.config.host_url, session_user.id, db_user.feed_key_version, &state.config.signing_secret, ); let custom_domain = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id) .await? .map(|d| { let instructions = if d.verified { String::new() } else { format!( "Point {0} at connect.makenot.work (CNAME, DNS-only) and add a TXT _mnw-verify.{0} with value {1}, then verify.", d.domain, d.verification_token ) }; crate::templates::CustomDomainInfo { id: d.id.to_string(), domain: d.domain, verified: d.verified, verification_token: d.verification_token, instructions, } }); Ok(UserProfileTabTemplate { user, custom_links, feed_url, can_create_projects: session_user.can_create_projects, custom_domain, }) } /// Regenerate the user's personal feed URL, revoking the previous one. /// /// Bumps `feed_key_version` (which is folded into the feed HMAC) and returns /// the refreshed feed-row partial for HTMX to swap in. Any feed URL the user /// had already shared stops verifying immediately. #[tracing::instrument(skip_all, name = "dashboard_tabs::regenerate_feed_url")] pub(in crate::routes::pages::dashboard) async fn regenerate_feed_url( State(state): State, AuthUser(session_user): AuthUser, ) -> Result { let version = db::users::bump_feed_key_version(&state.db, session_user.id).await?; let feed_url = helpers::generate_feed_url( &state.config.host_url, session_user.id, version, &state.config.signing_secret, ); // host_url is config (https origin), the id is a UUID, version an integer, // sig is hex — none can contain HTML metacharacters. Encode the `&` query // separator so the value attribute is well-formed; readers decode it back. let escaped = feed_url.replace('&', "&"); Ok(axum::response::Html(format!( "
\ \ \ \
" ))) } /// Render the HTMX partial for the dashboard account tab. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_account")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account( State(state): State, session: Session, AuthUser(session_user): AuthUser, ) -> Result { let db_user = db::users::get_user_by_id(&state.db, session_user.id) .await? .ok_or(AppError::NotFound)?; let user = User::from(&db_user); let sessions = db::sessions::get_user_sessions(&state.db, session_user.id).await?; let current_session_id = session .get::(SESSION_TRACKING_KEY) .await .ok() .flatten(); // Fetch moderation actions for "Account Status" section let active_actions = db::moderation::get_active_actions(&state.db, session_user.id).await?; let all_actions = db::moderation::get_history(&state.db, session_user.id).await?; let moderation_active: Vec = active_actions .iter() .map(|a| ModerationActionView { action_label: a.action_type.label().to_string(), reason: a.reason.clone(), created_at: a.created_at.format("%b %-d, %Y").to_string(), resolved_at: None, }) .collect(); let moderation_history: Vec = all_actions .iter() .filter(|a| a.resolved_at.is_some()) .map(|a| ModerationActionView { action_label: a.action_type.label().to_string(), reason: a.reason.clone(), created_at: a.created_at.format("%b %-d, %Y").to_string(), resolved_at: a.resolved_at.map(|d| d.format("%b %-d, %Y").to_string()), }) .collect(); let fan_plus = db::fan_plus::get_fan_plus_by_user(&state.db, session_user.id) .await? .filter(|sub| matches!(sub.status, db::SubscriptionStatus::Active | db::SubscriptionStatus::PastDue)) .map(|sub| crate::templates::FanPlusPaneView { period_end: sub.current_period_end.map(|d| d.format("%b %-d, %Y").to_string()), cancel_at_period_end: sub.cancel_at_period_end, }); let csrf_token = crate::csrf::get_or_create_token(&session).await.ok(); Ok(UserAccountTabTemplate { user, sessions, current_session_id, can_create_projects: session_user.can_create_projects, email_verified: db_user.email_verified, moderation_active, moderation_history, creator_paused: db_user.is_creator_paused(), fan_plus, csrf_token, }) } /// Render the HTMX partial for the dashboard projects tab. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_projects")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_projects( State(state): State, AuthUser(session_user): AuthUser, headers: HeaderMap, ) -> Result { let generation = db::users::get_cache_generation(&state.db, session_user.id).await?; if let Some(not_modified) = helpers::check_etag(&headers, generation) { return Ok(not_modified); } let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?; let projects: Vec = db_projects.iter().map(ProjectCard::from_db).collect(); Ok(helpers::with_etag( generation, UserProjectsTabTemplate { projects, can_create_projects: session_user.can_create_projects, }, )) } /// Support tab; submit a support ticket. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_support")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_support( AuthUser(session_user): AuthUser, ) -> Result { Ok(UserSupportTabTemplate { email: session_user.email.clone(), }) } /// SSH Keys tab; manage SSH keys for git access. #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_ssh_keys")] pub(in crate::routes::pages::dashboard) async fn dashboard_tab_ssh_keys( AuthUser(session_user): AuthUser, ) -> Result { let username = session_user.username.to_string(); Ok(UserSshKeysTabTemplate { username }) }