//! 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::{ build_payments, dashboard_tab_contacts, dashboard_tab_payments, dashboard_tab_payout_summary, dashboard_transactions, }; use crate::extractors::ValidatedQuery; use axum::extract::State; use axum::http::HeaderMap; use axum::response::IntoResponse; use tower_sessions::Session; use askama::Template as _; use crate::{ auth::{AuthUser, SESSION_TRACKING_KEY}, config::Config, db, error::{AppError, Result}, helpers, templates::{ CustomLinkWithId, ModerationActionView, UserAccountTabTemplate, UserProfileTabTemplate, UserProjectsTabTemplate, UserSettingsTabTemplate, UserSshKeysTabTemplate, UserSupportTabTemplate, }, types::{ProjectCard, User}, }; use sqlx::PgPool; /// Render the HTMX partial for the dashboard settings meta-tab. /// Includes the shown section inline; the others are 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(db): State, State(config): State, State(payments): State, session: Session, AuthUser(session_user): AuthUser, ValidatedQuery(query): ValidatedQuery, ) -> Result { let csrf_token = helpers::get_csrf_token(&session).await; build_settings( &db, &config, &payments, csrf_token, &session_user, query.section.as_deref(), ) .await } /// The settings tab's contents, without the transport around them. /// /// Split out 2026-08-19 for the described strip (`6b24f2df` step 5): the user /// dashboard can open on this tab, so the page needs what the route answers. /// `asked` is the `§ion=` half of the nested deep link (`3a7de032`): the /// section it names is rendered here rather than fetched, so a link to the /// Creator Plan arrives filled at first paint. pub(in crate::routes::pages::dashboard) async fn build_settings( db: &PgPool, config: &Config, payments: &crate::Billing, csrf_token: Option, session_user: &crate::auth::SessionUser, asked: Option<&str>, ) -> Result { let has_media = session_user.can_create_projects; let git_enabled = config.build.git_repos_path.is_some(); let has_mt_memberships = config.integrations.mt_base_url.is_some(); let shown = crate::quasi::settings_tabs::shown_at( asked, &config.quasi_screens, has_media, git_enabled, has_mt_memberships, ); // The shown section is rendered here rather than fetched, which is what the // `{% include %}` did for Profile before the sub-nav was described. // `6b24f2df`, nested by `3a7de032`. Only the shown one is built, so a reader // opening on Profile pays nothing for the two the deep link can reach. let section = match crate::quasi::settings_tabs::section_at( shown, has_media, git_enabled, has_mt_memberships, ) { "creator" => creator::build_creator(db, config, payments, csrf_token, session_user) .await? .render(), "ssh-keys" => build_ssh_keys(db, session_user).await?.render(), _ => build_profile(db, config, session_user).await?.render(), } .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?; Ok(UserSettingsTabTemplate { sections: crate::quasi::settings_tabs::html( &config.quasi_screens, shown, §ion, 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( db: State, config: State, session_user: AuthUser, ) -> Result { dashboard_tab_profile(db, config, 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(db): State, State(config): State, AuthUser(session_user): AuthUser, ) -> Result { build_profile(&db, &config, &session_user).await } /// The profile section's contents, without the transport around them. /// /// The settings tab renders this inline rather than fetching it, and did so /// through a copy of this body until `3a7de032` gave the sub-nav a second /// fillable section and the copy had to become a call. async fn build_profile( db: &PgPool, config: &Config, session_user: &crate::auth::SessionUser, ) -> Result { let db_user = db::users::get_user_by_id(db, session_user.id) .await? .ok_or(AppError::NotFound)?; let db_links = db::custom_links::get_custom_links_by_user(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( &config.host_url, session_user.id, db_user.feed_key_version, &config.signing_secret, ); let custom_domain = db::custom_domains::get_custom_domain_by_user(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, theme_options: crate::theming::theme_options(db_user.theme_id.as_deref()), }) } /// 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(db): State, State(config): State, AuthUser(session_user): AuthUser, ) -> Result { let version = db::users::bump_feed_key_version(&db, session_user.id).await?; let feed_url = helpers::generate_feed_url( &config.host_url, session_user.id, version, &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(db): State, session: Session, AuthUser(session_user): AuthUser, ) -> Result { let db_user = db::users::get_user_by_id(&db, session_user.id) .await? .ok_or(AppError::NotFound)?; let user = User::from(&db_user); let sessions = db::sessions::get_user_sessions(&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(&db, session_user.id).await?; let all_actions = db::moderation::get_history(&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(&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(); let notifications = db::lists::notification_prefs(&db, session_user.id).await?; Ok(UserAccountTabTemplate { user, notifications, operational_mail: crate::templates::OperationalMailRow::all(), 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(db): State, AuthUser(session_user): AuthUser, headers: HeaderMap, ) -> Result { let generation = db::users::get_cache_generation(&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(&db, session_user.id).await?; let projects: Vec = db_projects.iter().map(ProjectCard::from_db).collect(); Ok(helpers::with_etag( generation, build_projects(projects, &session_user), )) } /// The projects tab's contents, without the transport around them. /// /// Takes the cards rather than the pool: the dashboard page already builds this /// exact vector for itself, so the described strip (`6b24f2df` step 5) fills its /// shown panel without a second query. pub(in crate::routes::pages::dashboard) fn build_projects( projects: Vec, session_user: &crate::auth::SessionUser, ) -> UserProjectsTabTemplate { 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(build_support(&session_user)) } /// The support tab's contents. A deactivated account opens on this one, so the /// dashboard page renders it rather than fetching it (`6b24f2df` step 5). pub(in crate::routes::pages::dashboard) fn build_support( session_user: &crate::auth::SessionUser, ) -> UserSupportTabTemplate { 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( State(db): State, AuthUser(session_user): AuthUser, ) -> Result { build_ssh_keys(&db, &session_user).await } /// The SSH-keys section's contents, without the transport around them. /// /// Fillable from the settings tab (`3a7de032`) so "Manage SSH Keys" on the /// project code tab lands on the section rather than on Profile. This is the /// Askama rendering; when `QUASI_SCREENS` names `crate::quasi::ssh_keys` that /// screen answers the address instead and the section is fetched on a press, /// which is what `settings_tabs::shown_at` checks the switch for. async fn build_ssh_keys( db: &PgPool, session_user: &crate::auth::SessionUser, ) -> Result { let db_user = db::users::get_user_by_id(db, session_user.id) .await? .ok_or(AppError::NotFound)?; let username = session_user.username.to_string(); Ok(UserSshKeysTabTemplate { username, theme_options: crate::theming::console_theme_options(db_user.console_theme.as_deref()), }) }