Skip to main content

max / makenotwork

Phase 22: Forums dashboard tab, admin header link Add Forums tab to user dashboard (fetches MT membership data via mt_base_url config). Add admin link to site header using config-based is_admin on SessionUser. Bump to 0.2.8. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-15 21:46 UTC
Commit: 2536e585635dea94aa9be09622812df449b67e0f
Parent: 0c29e3f
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 {