max / makenotwork
18 files changed,
+190 insertions,
-3 deletions
| @@ -3453,7 +3453,7 @@ dependencies = [ | |||
| 3453 | 3453 | ||
| 3454 | 3454 | [[package]] | |
| 3455 | 3455 | name = "makenotwork" | |
| 3456 | - | version = "0.2.6" | |
| 3456 | + | version = "0.2.7" | |
| 3457 | 3457 | dependencies = [ | |
| 3458 | 3458 | "ammonia", | |
| 3459 | 3459 | "anyhow", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.2.7" | |
| 3 | + | version = "0.2.8" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -50,6 +50,8 @@ pub struct SessionUser { | |||
| 50 | 50 | pub can_create_projects: bool, | |
| 51 | 51 | #[serde(default)] | |
| 52 | 52 | pub suspended: bool, | |
| 53 | + | #[serde(default)] | |
| 54 | + | pub is_admin: bool, | |
| 53 | 55 | } | |
| 54 | 56 | ||
| 55 | 57 | impl SessionUser { | |
| @@ -335,6 +337,7 @@ mod tests { | |||
| 335 | 337 | display_name: None, | |
| 336 | 338 | can_create_projects: true, | |
| 337 | 339 | suspended: false, | |
| 340 | + | is_admin: true, | |
| 338 | 341 | }; | |
| 339 | 342 | let config = Config { | |
| 340 | 343 | host: "127.0.0.1".parse().unwrap(), | |
| @@ -353,6 +356,7 @@ mod tests { | |||
| 353 | 356 | postmark_webhook_token: None, | |
| 354 | 357 | postmark_broadcast_webhook_token: None, | |
| 355 | 358 | git_ssh_host: None, | |
| 359 | + | mt_base_url: None, | |
| 356 | 360 | }; | |
| 357 | 361 | assert!(require_admin(&user, &config).is_ok()); | |
| 358 | 362 | } | |
| @@ -383,6 +387,7 @@ mod tests { | |||
| 383 | 387 | display_name: None, | |
| 384 | 388 | can_create_projects: false, | |
| 385 | 389 | suspended: false, | |
| 390 | + | is_admin: false, | |
| 386 | 391 | }; | |
| 387 | 392 | let config = Config { | |
| 388 | 393 | host: "127.0.0.1".parse().unwrap(), | |
| @@ -401,6 +406,7 @@ mod tests { | |||
| 401 | 406 | postmark_webhook_token: None, | |
| 402 | 407 | postmark_broadcast_webhook_token: None, | |
| 403 | 408 | git_ssh_host: None, | |
| 409 | + | mt_base_url: None, | |
| 404 | 410 | }; | |
| 405 | 411 | assert!(require_admin(&user, &config).is_err()); | |
| 406 | 412 | } |
| @@ -39,6 +39,9 @@ pub struct Config { | |||
| 39 | 39 | pub postmark_broadcast_webhook_token: Option<String>, | |
| 40 | 40 | /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set. | |
| 41 | 41 | pub git_ssh_host: Option<String>, | |
| 42 | + | /// Base URL of the Multithreaded forum instance (e.g., "https://forums.makenot.work"). | |
| 43 | + | /// When set, enables the Forums tab on the user dashboard. | |
| 44 | + | pub mt_base_url: Option<String>, | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | 47 | /// S3-compatible storage configuration (Hetzner Object Storage) | |
| @@ -127,6 +130,9 @@ impl Config { | |||
| 127 | 130 | // Git SSH host - optional, SSH clone URL hidden when unset | |
| 128 | 131 | let git_ssh_host = std::env::var("GIT_SSH_HOST").ok(); | |
| 129 | 132 | ||
| 133 | + | // Multithreaded forum base URL - optional, Forums tab hidden when unset | |
| 134 | + | let mt_base_url = std::env::var("MT_BASE_URL").ok(); | |
| 135 | + | ||
| 130 | 136 | Ok(Config { | |
| 131 | 137 | host, | |
| 132 | 138 | port, | |
| @@ -144,6 +150,7 @@ impl Config { | |||
| 144 | 150 | postmark_webhook_token, | |
| 145 | 151 | postmark_broadcast_webhook_token, | |
| 146 | 152 | git_ssh_host, | |
| 153 | + | mt_base_url, | |
| 147 | 154 | }) | |
| 148 | 155 | } | |
| 149 | 156 | ||
| @@ -271,6 +278,7 @@ impl std::fmt::Debug for Config { | |||
| 271 | 278 | .field("postmark_webhook_token", &self.postmark_webhook_token.as_ref().map(|_| "[REDACTED]")) | |
| 272 | 279 | .field("postmark_broadcast_webhook_token", &self.postmark_broadcast_webhook_token.as_ref().map(|_| "[REDACTED]")) | |
| 273 | 280 | .field("git_ssh_host", &self.git_ssh_host) | |
| 281 | + | .field("mt_base_url", &self.mt_base_url) | |
| 274 | 282 | .finish() | |
| 275 | 283 | } | |
| 276 | 284 | } | |
| @@ -332,6 +340,7 @@ mod tests { | |||
| 332 | 340 | postmark_webhook_token: None, | |
| 333 | 341 | postmark_broadcast_webhook_token: None, | |
| 334 | 342 | git_ssh_host: None, | |
| 343 | + | mt_base_url: None, | |
| 335 | 344 | }; | |
| 336 | 345 | let addr = config.socket_addr(); | |
| 337 | 346 | assert_eq!(addr.port(), 8080); |
| @@ -181,6 +181,7 @@ async fn login_handler( | |||
| 181 | 181 | ||
| 182 | 182 | // Create session | |
| 183 | 183 | let suspended = user.is_suspended(); | |
| 184 | + | let is_admin = state.config.admin_user_id == Some(user.id); | |
| 184 | 185 | let session_user = SessionUser { | |
| 185 | 186 | id: user.id, | |
| 186 | 187 | username: user.username, | |
| @@ -188,6 +189,7 @@ async fn login_handler( | |||
| 188 | 189 | display_name: user.display_name, | |
| 189 | 190 | can_create_projects: user.can_create_projects, | |
| 190 | 191 | suspended, | |
| 192 | + | is_admin, | |
| 191 | 193 | }; | |
| 192 | 194 | ||
| 193 | 195 | login_user(&session, session_user).await?; | |
| @@ -355,7 +357,7 @@ async fn join_handler( | |||
| 355 | 357 | let user_email = user.email.clone(); | |
| 356 | 358 | let user_display_name = user.display_name.clone(); | |
| 357 | 359 | ||
| 358 | - | // Create session (new users never have creator access or suspensions) | |
| 360 | + | // Create session (new users never have creator access, suspensions, or admin) | |
| 359 | 361 | let session_user = SessionUser { | |
| 360 | 362 | id: user.id, | |
| 361 | 363 | username: user.username, | |
| @@ -363,6 +365,7 @@ async fn join_handler( | |||
| 363 | 365 | display_name: user.display_name, | |
| 364 | 366 | can_create_projects: false, | |
| 365 | 367 | suspended: false, | |
| 368 | + | is_admin: false, | |
| 366 | 369 | }; | |
| 367 | 370 | ||
| 368 | 371 | login_user(&session, session_user).await?; | |
| @@ -582,6 +585,7 @@ async fn passkey_auth_finish( | |||
| 582 | 585 | ||
| 583 | 586 | // Create session — passkeys skip TOTP (inherently two-factor) | |
| 584 | 587 | let suspended = user.is_suspended(); | |
| 588 | + | let is_admin = state.config.admin_user_id == Some(user.id); | |
| 585 | 589 | let session_user = SessionUser { | |
| 586 | 590 | id: user.id, | |
| 587 | 591 | username: user.username, | |
| @@ -589,6 +593,7 @@ async fn passkey_auth_finish( | |||
| 589 | 593 | display_name: user.display_name.clone(), | |
| 590 | 594 | can_create_projects: user.can_create_projects, | |
| 591 | 595 | suspended, | |
| 596 | + | is_admin, | |
| 592 | 597 | }; | |
| 593 | 598 | ||
| 594 | 599 | login_user(&session, session_user).await?; |
| @@ -100,6 +100,27 @@ pub(super) async fn dashboard( | |||
| 100 | 100 | None | |
| 101 | 101 | }; | |
| 102 | 102 | ||
| 103 | + | // Check if user has MT forum memberships (for Forums tab visibility) | |
| 104 | + | let has_mt_memberships = if let Some(ref mt_url) = state.config.mt_base_url { | |
| 105 | + | let url = format!("{}/api/user/{}/summary", mt_url, session_user.id); | |
| 106 | + | match reqwest::Client::new() | |
| 107 | + | .get(&url) | |
| 108 | + | .timeout(std::time::Duration::from_secs(3)) | |
| 109 | + | .send() | |
| 110 | + | .await | |
| 111 | + | { | |
| 112 | + | Ok(resp) if resp.status().is_success() => resp | |
| 113 | + | .json::<serde_json::Value>() | |
| 114 | + | .await | |
| 115 | + | .ok() | |
| 116 | + | .and_then(|j| j["memberships"].as_array().map(|a| !a.is_empty())) | |
| 117 | + | .unwrap_or(false), | |
| 118 | + | _ => false, | |
| 119 | + | } | |
| 120 | + | } else { | |
| 121 | + | false | |
| 122 | + | }; | |
| 123 | + | ||
| 103 | 124 | let suspended = db_user.is_suspended(); | |
| 104 | 125 | let suspension_reason = db_user.suspension_reason.clone(); | |
| 105 | 126 | let has_pending_appeal = db_user.appeal_submitted_at.is_some() && db_user.appeal_decided_at.is_none(); | |
| @@ -113,6 +134,7 @@ pub(super) async fn dashboard( | |||
| 113 | 134 | transactions, | |
| 114 | 135 | projects, | |
| 115 | 136 | onboarding, | |
| 137 | + | has_mt_memberships, | |
| 116 | 138 | suspended, | |
| 117 | 139 | suspension_reason, | |
| 118 | 140 | has_pending_appeal, |
| @@ -47,6 +47,7 @@ pub fn dashboard_routes() -> Router<AppState> { | |||
| 47 | 47 | .route("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator)) | |
| 48 | 48 | .route("/dashboard/tabs/analytics", get(tabs::dashboard_tab_analytics)) | |
| 49 | 49 | .route("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit)) | |
| 50 | + | .route("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums)) | |
| 50 | 51 | .route("/dashboard/transactions", get(tabs::dashboard_transactions)) | |
| 51 | 52 | .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview)) | |
| 52 | 53 | .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content)) |
| @@ -320,6 +320,76 @@ pub(super) async fn dashboard_transactions( | |||
| 320 | 320 | Ok(TransactionsTableTemplate { transactions }) | |
| 321 | 321 | } | |
| 322 | 322 | ||
| 323 | + | /// Render the HTMX partial for the Forums tab (Multithreaded community memberships). | |
| 324 | + | #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_forums")] | |
| 325 | + | pub(super) async fn dashboard_tab_forums( | |
| 326 | + | State(state): State<AppState>, | |
| 327 | + | AuthUser(session_user): AuthUser, | |
| 328 | + | ) -> Result<impl IntoResponse> { | |
| 329 | + | let mt_base_url = state | |
| 330 | + | .config | |
| 331 | + | .mt_base_url | |
| 332 | + | .as_ref() | |
| 333 | + | .ok_or(AppError::NotFound)?; | |
| 334 | + | ||
| 335 | + | let url = format!("{}/api/user/{}/summary", mt_base_url, session_user.id); | |
| 336 | + | ||
| 337 | + | let resp = reqwest::Client::new() | |
| 338 | + | .get(&url) | |
| 339 | + | .timeout(std::time::Duration::from_secs(5)) | |
| 340 | + | .send() | |
| 341 | + | .await | |
| 342 | + | .map_err(|e| { | |
| 343 | + | tracing::warn!(error = ?e, "failed to fetch MT user summary"); | |
| 344 | + | AppError::Internal(anyhow::anyhow!("MT API unavailable")) | |
| 345 | + | })?; | |
| 346 | + | ||
| 347 | + | if !resp.status().is_success() { | |
| 348 | + | return Ok(UserForumsTabTemplate { | |
| 349 | + | memberships: vec![], | |
| 350 | + | mt_base_url: mt_base_url.clone(), | |
| 351 | + | } | |
| 352 | + | .into_response()); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | let json: serde_json::Value = resp.json().await.map_err(|e| { | |
| 356 | + | tracing::warn!(error = ?e, "failed to parse MT summary response"); | |
| 357 | + | AppError::Internal(anyhow::anyhow!("MT API response invalid")) | |
| 358 | + | })?; | |
| 359 | + | ||
| 360 | + | let memberships = json["memberships"] | |
| 361 | + | .as_array() | |
| 362 | + | .map(|arr| { | |
| 363 | + | arr.iter() | |
| 364 | + | .filter_map(|m| { | |
| 365 | + | let community_slug = m["community_slug"].as_str()?; | |
| 366 | + | let username = &session_user.username; | |
| 367 | + | Some(ForumMembership { | |
| 368 | + | community_name: m["community_name"].as_str()?.to_string(), | |
| 369 | + | profile_url: format!( | |
| 370 | + | "{}/p/{}/u/{}", | |
| 371 | + | mt_base_url, community_slug, username | |
| 372 | + | ), | |
| 373 | + | role: m["role"].as_str()?.to_string(), | |
| 374 | + | joined: m["joined_at"] | |
| 375 | + | .as_str() | |
| 376 | + | .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) | |
| 377 | + | .map(|dt| dt.format("%b %d, %Y").to_string()) | |
| 378 | + | .unwrap_or_default(), | |
| 379 | + | post_count: m["post_count"].as_i64().unwrap_or(0), | |
| 380 | + | }) | |
| 381 | + | }) | |
| 382 | + | .collect() | |
| 383 | + | }) | |
| 384 | + | .unwrap_or_default(); | |
| 385 | + | ||
| 386 | + | Ok(UserForumsTabTemplate { | |
| 387 | + | memberships, | |
| 388 | + | mt_base_url: mt_base_url.clone(), | |
| 389 | + | } | |
| 390 | + | .into_response()) | |
| 391 | + | } | |
| 392 | + | ||
| 323 | 393 | /// Render the HTMX partial for the SyncKit tab. | |
| 324 | 394 | #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_synckit")] | |
| 325 | 395 | pub(super) async fn dashboard_tab_synckit( |
| @@ -417,6 +417,7 @@ async fn login_link_handler( | |||
| 417 | 417 | ||
| 418 | 418 | // Create session | |
| 419 | 419 | let suspended = user.is_suspended(); | |
| 420 | + | let is_admin = state.config.admin_user_id == Some(user.id); | |
| 420 | 421 | let session_user = SessionUser { | |
| 421 | 422 | id: user.id, | |
| 422 | 423 | username: user.username, | |
| @@ -424,6 +425,7 @@ async fn login_link_handler( | |||
| 424 | 425 | display_name: user.display_name, | |
| 425 | 426 | can_create_projects: user.can_create_projects, | |
| 426 | 427 | suspended, | |
| 428 | + | is_admin, | |
| 427 | 429 | }; | |
| 428 | 430 | ||
| 429 | 431 | login_user(&session, session_user).await?; |
| @@ -127,6 +127,7 @@ pub(super) async fn verify_two_factor( | |||
| 127 | 127 | ||
| 128 | 128 | // Complete login | |
| 129 | 129 | let suspended = user.is_suspended(); | |
| 130 | + | let is_admin = state.config.admin_user_id == Some(user.id); | |
| 130 | 131 | let session_user = SessionUser { | |
| 131 | 132 | id: user.id, | |
| 132 | 133 | username: user.username, | |
| @@ -134,6 +135,7 @@ pub(super) async fn verify_two_factor( | |||
| 134 | 135 | display_name: user.display_name.clone(), | |
| 135 | 136 | can_create_projects: user.can_create_projects, | |
| 136 | 137 | suspended, | |
| 138 | + | is_admin, | |
| 137 | 139 | }; | |
| 138 | 140 | ||
| 139 | 141 | login_user(&session, session_user).await?; |
| @@ -24,6 +24,8 @@ pub struct DashboardUserTemplate { | |||
| 24 | 24 | pub projects: Vec<ProjectCard>, | |
| 25 | 25 | /// Onboarding checklist for new creators (None once all steps complete). | |
| 26 | 26 | pub onboarding: Option<OnboardingChecklist>, | |
| 27 | + | /// Whether user has MT forum memberships (controls Forums tab visibility). | |
| 28 | + | pub has_mt_memberships: bool, | |
| 27 | 29 | // Suspension context (for banner) | |
| 28 | 30 | pub suspended: bool, | |
| 29 | 31 | pub suspension_reason: Option<String>, |
| @@ -137,6 +137,8 @@ impl_into_response!( | |||
| 137 | 137 | UserSessionsPartialTemplate, | |
| 138 | 138 | // SyncKit | |
| 139 | 139 | UserSyncKitTabTemplate, | |
| 140 | + | // Forums (Multithreaded) | |
| 141 | + | UserForumsTabTemplate, | |
| 140 | 142 | // Follow button | |
| 141 | 143 | FollowButtonTemplate, | |
| 142 | 144 | TagFollowToggleTemplate, |
| @@ -280,6 +280,23 @@ pub struct UserSyncKitTabTemplate { | |||
| 280 | 280 | pub projects: Vec<ProjectCard>, | |
| 281 | 281 | } | |
| 282 | 282 | ||
| 283 | + | /// Row in the Forums tab showing a community membership. | |
| 284 | + | pub struct ForumMembership { | |
| 285 | + | pub community_name: String, | |
| 286 | + | pub profile_url: String, | |
| 287 | + | pub role: String, | |
| 288 | + | pub joined: String, | |
| 289 | + | pub post_count: i64, | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// Forums tab in the user dashboard — lists MT community memberships. | |
| 293 | + | #[derive(Template)] | |
| 294 | + | #[template(path = "partials/tabs/user_forums.html")] | |
| 295 | + | pub struct UserForumsTabTemplate { | |
| 296 | + | pub memberships: Vec<ForumMembership>, | |
| 297 | + | pub mt_base_url: String, | |
| 298 | + | } | |
| 299 | + | ||
| 283 | 300 | /// Per-project revenue for the user analytics top projects list. | |
| 284 | 301 | pub struct ProjectRevenue { | |
| 285 | 302 | pub title: String, |
| @@ -131,6 +131,18 @@ | |||
| 131 | 131 | hx-indicator="#tab-spinner" | |
| 132 | 132 | onclick="setActiveTab(this)">SyncKit</button> | |
| 133 | 133 | {% endif %}{% endif %} | |
| 134 | + | {% if has_mt_memberships %} | |
| 135 | + | <button class="tab" | |
| 136 | + | role="tab" | |
| 137 | + | aria-selected="false" | |
| 138 | + | aria-controls="tab-content" | |
| 139 | + | id="tab-forums" | |
| 140 | + | hx-get="/dashboard/tabs/forums" | |
| 141 | + | hx-target="#tab-content" | |
| 142 | + | hx-swap="innerHTML" | |
| 143 | + | hx-indicator="#tab-spinner" | |
| 144 | + | onclick="setActiveTab(this)">Forums</button> | |
| 145 | + | {% endif %} | |
| 134 | 146 | <span id="tab-spinner" class="htmx-indicator" style="margin-left: 1rem;" aria-live="polite"> Loading...</span> | |
| 135 | 147 | </div> | |
| 136 | 148 |
| @@ -14,6 +14,7 @@ | |||
| 14 | 14 | <a href="/feed">Feed</a> | |
| 15 | 15 | <a href="/u/{{ user.username }}">Profile</a> | |
| 16 | 16 | <a href="/dashboard">Dashboard</a> | |
| 17 | + | {% if user.is_admin %}<a href="/admin/waitlist">Admin</a>{% endif %} | |
| 17 | 18 | <form action="/logout" method="post" class="nav-form"> | |
| 18 | 19 | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 19 | 20 | <button type="submit" class="link-button" aria-label="Log out">Log Out</button> |
| @@ -0,0 +1,34 @@ | |||
| 1 | + | <div class="content-section"> | |
| 2 | + | <div class="section-header"> | |
| 3 | + | <h2>Forum Communities</h2> | |
| 4 | + | </div> | |
| 5 | + | ||
| 6 | + | <p class="form-hint" style="margin-bottom: 1.5rem;">Your memberships across <a href="{{ mt_base_url }}">Multithreaded</a> forum communities.</p> | |
| 7 | + | ||
| 8 | + | {% if memberships.is_empty() %} | |
| 9 | + | <p class="muted">You haven't joined any forum communities yet.</p> | |
| 10 | + | {% else %} | |
| 11 | + | <div class="scroll-x"> | |
| 12 | + | <table class="data-table"> | |
| 13 | + | <thead> | |
| 14 | + | <tr> | |
| 15 | + | <th>Community</th> | |
| 16 | + | <th class="col-role">Role</th> | |
| 17 | + | <th>Posts</th> | |
| 18 | + | <th>Joined</th> | |
| 19 | + | </tr> | |
| 20 | + | </thead> | |
| 21 | + | <tbody> | |
| 22 | + | {% for m in memberships %} | |
| 23 | + | <tr> | |
| 24 | + | <td><a href="{{ m.profile_url }}">{{ m.community_name }}</a></td> | |
| 25 | + | <td class="col-role"><span class="badge badge-role-{{ m.role }}">{{ m.role }}</span></td> | |
| 26 | + | <td>{{ m.post_count }}</td> | |
| 27 | + | <td>{{ m.joined }}</td> | |
| 28 | + | </tr> | |
| 29 | + | {% endfor %} | |
| 30 | + | </tbody> | |
| 31 | + | </table> | |
| 32 | + | </div> | |
| 33 | + | {% endif %} | |
| 34 | + | </div> |
| @@ -212,6 +212,7 @@ impl TestHarness { | |||
| 212 | 212 | postmark_webhook_token: opts.postmark_webhook_token, | |
| 213 | 213 | postmark_broadcast_webhook_token: opts.postmark_broadcast_webhook_token, | |
| 214 | 214 | git_ssh_host: None, | |
| 215 | + | mt_base_url: None, | |
| 215 | 216 | }; | |
| 216 | 217 | ||
| 217 | 218 | let email = EmailClient::new(EmailConfig { |
| @@ -66,6 +66,7 @@ pub async fn run(config: LoadConfig) { | |||
| 66 | 66 | postmark_webhook_token: None, | |
| 67 | 67 | postmark_broadcast_webhook_token: None, | |
| 68 | 68 | git_ssh_host: None, | |
| 69 | + | mt_base_url: None, | |
| 69 | 70 | }; | |
| 70 | 71 | ||
| 71 | 72 | let email = EmailClient::new(EmailConfig { |