Skip to main content

max / makenotwork

14.6 KB · 404 lines History Blame Raw
1 //! User-level dashboard tab handlers.
2
3 mod creator;
4 mod integrations;
5 mod payments;
6
7 pub(in crate::routes::pages::dashboard) use creator::{
8 dashboard_tab_analytics, dashboard_tab_creator,
9 };
10 pub(in crate::routes::pages::dashboard) use integrations::{
11 dashboard_tab_forums, dashboard_tab_media, dashboard_tab_synckit,
12 };
13 pub(in crate::routes::pages::dashboard) use payments::{
14 build_payments, dashboard_tab_contacts, dashboard_tab_payments, dashboard_tab_payout_summary,
15 dashboard_transactions,
16 };
17
18 use crate::extractors::ValidatedQuery;
19 use axum::extract::State;
20 use axum::http::HeaderMap;
21 use axum::response::IntoResponse;
22 use tower_sessions::Session;
23
24 use askama::Template as _;
25
26 use crate::{
27 auth::{AuthUser, SESSION_TRACKING_KEY},
28 config::Config,
29 db,
30 error::{AppError, Result},
31 helpers,
32 templates::{
33 CustomLinkWithId, ModerationActionView, UserAccountTabTemplate, UserProfileTabTemplate,
34 UserProjectsTabTemplate, UserSettingsTabTemplate, UserSshKeysTabTemplate,
35 UserSupportTabTemplate,
36 },
37 types::{ProjectCard, User},
38 };
39 use sqlx::PgPool;
40
41 /// Render the HTMX partial for the dashboard settings meta-tab.
42 /// Includes the shown section inline; the others are loaded via HTMX sub-nav.
43 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_settings")]
44 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings(
45 State(db): State<PgPool>,
46 State(config): State<Config>,
47 State(payments): State<crate::Billing>,
48 session: Session,
49 AuthUser(session_user): AuthUser,
50 ValidatedQuery(query): ValidatedQuery<super::super::SectionQuery>,
51 ) -> Result<impl IntoResponse> {
52 let csrf_token = helpers::get_csrf_token(&session).await;
53 build_settings(
54 &db,
55 &config,
56 &payments,
57 csrf_token,
58 &session_user,
59 query.section.as_deref(),
60 )
61 .await
62 }
63
64 /// The settings tab's contents, without the transport around them.
65 ///
66 /// Split out 2026-08-19 for the described strip (`6b24f2df` step 5): the user
67 /// dashboard can open on this tab, so the page needs what the route answers.
68 /// `asked` is the `&section=` half of the nested deep link (`3a7de032`): the
69 /// section it names is rendered here rather than fetched, so a link to the
70 /// Creator Plan arrives filled at first paint.
71 pub(in crate::routes::pages::dashboard) async fn build_settings(
72 db: &PgPool,
73 config: &Config,
74 payments: &crate::Billing,
75 csrf_token: Option<String>,
76 session_user: &crate::auth::SessionUser,
77 asked: Option<&str>,
78 ) -> Result<UserSettingsTabTemplate> {
79 let has_media = session_user.can_create_projects;
80 let git_enabled = config.build.git_repos_path.is_some();
81 let has_mt_memberships = config.integrations.mt_base_url.is_some();
82
83 let shown = crate::quasi::settings_tabs::shown_at(
84 asked,
85 &config.quasi_screens,
86 has_media,
87 git_enabled,
88 has_mt_memberships,
89 );
90
91 // The shown section is rendered here rather than fetched, which is what the
92 // `{% include %}` did for Profile before the sub-nav was described.
93 // `6b24f2df`, nested by `3a7de032`. Only the shown one is built, so a reader
94 // opening on Profile pays nothing for the two the deep link can reach.
95 let section = match crate::quasi::settings_tabs::section_at(
96 shown,
97 has_media,
98 git_enabled,
99 has_mt_memberships,
100 ) {
101 "creator" => creator::build_creator(db, config, payments, csrf_token, session_user)
102 .await?
103 .render(),
104 "ssh-keys" => build_ssh_keys(db, session_user).await?.render(),
105 _ => build_profile(db, config, session_user).await?.render(),
106 }
107 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
108
109 Ok(UserSettingsTabTemplate {
110 sections: crate::quasi::settings_tabs::html(
111 &config.quasi_screens,
112 shown,
113 &section,
114 has_media,
115 git_enabled,
116 has_mt_memberships,
117 ),
118 })
119 }
120
121 /// Legacy route; redirects to the profile tab.
122 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_details(
123 db: State<PgPool>,
124 config: State<Config>,
125 session_user: AuthUser,
126 ) -> Result<impl IntoResponse> {
127 dashboard_tab_profile(db, config, session_user).await
128 }
129
130 /// Render the HTMX partial for the dashboard profile tab.
131 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_profile")]
132 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_profile(
133 State(db): State<PgPool>,
134 State(config): State<Config>,
135 AuthUser(session_user): AuthUser,
136 ) -> Result<impl IntoResponse> {
137 build_profile(&db, &config, &session_user).await
138 }
139
140 /// The profile section's contents, without the transport around them.
141 ///
142 /// The settings tab renders this inline rather than fetching it, and did so
143 /// through a copy of this body until `3a7de032` gave the sub-nav a second
144 /// fillable section and the copy had to become a call.
145 async fn build_profile(
146 db: &PgPool,
147 config: &Config,
148 session_user: &crate::auth::SessionUser,
149 ) -> Result<UserProfileTabTemplate> {
150 let db_user = db::users::get_user_by_id(db, session_user.id)
151 .await?
152 .ok_or(AppError::NotFound)?;
153
154 let db_links = db::custom_links::get_custom_links_by_user(db, session_user.id).await?;
155
156 let user = User::from(&db_user);
157
158 let custom_links: Vec<CustomLinkWithId> = db_links
159 .into_iter()
160 .map(|l| CustomLinkWithId {
161 id: l.id.to_string(),
162 url: l.url,
163 title: l.title,
164 })
165 .collect();
166
167 let feed_url = helpers::generate_feed_url(
168 &config.host_url,
169 session_user.id,
170 db_user.feed_key_version,
171 &config.signing_secret,
172 );
173
174 let custom_domain =
175 db::custom_domains::get_custom_domain_by_user(db, session_user.id)
176 .await?
177 .map(|d| {
178 let instructions = if d.verified {
179 String::new()
180 } else {
181 format!(
182 "Point {0} at connect.makenot.work (CNAME, DNS-only) and add a TXT _mnw-verify.{0} with value {1}, then verify.",
183 d.domain, d.verification_token
184 )
185 };
186 crate::templates::CustomDomainInfo {
187 id: d.id.to_string(),
188 domain: d.domain,
189 verified: d.verified,
190 verification_token: d.verification_token,
191 instructions,
192 }
193 });
194
195 Ok(UserProfileTabTemplate {
196 user,
197 custom_links,
198 feed_url,
199 can_create_projects: session_user.can_create_projects,
200 custom_domain,
201 theme_options: crate::theming::theme_options(db_user.theme_id.as_deref()),
202 })
203 }
204
205 /// Regenerate the user's personal feed URL, revoking the previous one.
206 ///
207 /// Bumps `feed_key_version` (which is folded into the feed HMAC) and returns
208 /// the refreshed feed-row partial for HTMX to swap in. Any feed URL the user
209 /// had already shared stops verifying immediately.
210 #[tracing::instrument(skip_all, name = "dashboard_tabs::regenerate_feed_url")]
211 pub(in crate::routes::pages::dashboard) async fn regenerate_feed_url(
212 State(db): State<PgPool>,
213 State(config): State<Config>,
214 AuthUser(session_user): AuthUser,
215 ) -> Result<impl IntoResponse> {
216 let version = db::users::bump_feed_key_version(&db, session_user.id).await?;
217 let feed_url = helpers::generate_feed_url(
218 &config.host_url,
219 session_user.id,
220 version,
221 &config.signing_secret,
222 );
223
224 // host_url is config (https origin), the id is a UUID, version an integer,
225 // sig is hex, none can contain HTML metacharacters. Encode the `&` query
226 // separator so the value attribute is well-formed; readers decode it back.
227 let escaped = feed_url.replace('&', "&amp;");
228 Ok(axum::response::Html(format!(
229 "<div class=\"profile-feed-row\" id=\"feed-url-row\">\
230 <input type=\"text\" id=\"feed-url\" value=\"{escaped}\" readonly class=\"profile-feed-input\">\
231 <button class=\"btn-secondary nowrap\" type=\"button\" \
232 onclick=\"navigator.clipboard.writeText(document.getElementById('feed-url').value).then(() =&gt; {{ this.textContent='Copied!'; setTimeout(() =&gt; this.textContent='Copy URL', 2000) }})\">Copy URL</button>\
233 <button class=\"btn-secondary nowrap\" type=\"button\" \
234 hx-post=\"/dashboard/feed/regenerate\" hx-target=\"#feed-url-row\" hx-swap=\"outerHTML\">Regenerate</button>\
235 </div>"
236 )))
237 }
238
239 /// Render the HTMX partial for the dashboard account tab.
240 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_account")]
241 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
242 State(db): State<PgPool>,
243 session: Session,
244 AuthUser(session_user): AuthUser,
245 ) -> Result<impl IntoResponse> {
246 let db_user = db::users::get_user_by_id(&db, session_user.id)
247 .await?
248 .ok_or(AppError::NotFound)?;
249
250 let user = User::from(&db_user);
251
252 let sessions = db::sessions::get_user_sessions(&db, session_user.id).await?;
253 let current_session_id = session
254 .get::<db::UserSessionId>(SESSION_TRACKING_KEY)
255 .await
256 .ok()
257 .flatten();
258
259 // Fetch moderation actions for "Account Status" section
260 let active_actions = db::moderation::get_active_actions(&db, session_user.id).await?;
261 let all_actions = db::moderation::get_history(&db, session_user.id).await?;
262
263 let moderation_active: Vec<ModerationActionView> = active_actions
264 .iter()
265 .map(|a| ModerationActionView {
266 action_label: a.action_type.label().to_string(),
267 reason: a.reason.clone(),
268 created_at: a.created_at.format("%b %-d, %Y").to_string(),
269 resolved_at: None,
270 })
271 .collect();
272
273 let moderation_history: Vec<ModerationActionView> = all_actions
274 .iter()
275 .filter(|a| a.resolved_at.is_some())
276 .map(|a| ModerationActionView {
277 action_label: a.action_type.label().to_string(),
278 reason: a.reason.clone(),
279 created_at: a.created_at.format("%b %-d, %Y").to_string(),
280 resolved_at: a.resolved_at.map(|d| d.format("%b %-d, %Y").to_string()),
281 })
282 .collect();
283
284 let fan_plus = db::fan_plus::get_fan_plus_by_user(&db, session_user.id)
285 .await?
286 .filter(|sub| {
287 matches!(
288 sub.status,
289 db::SubscriptionStatus::Active | db::SubscriptionStatus::PastDue
290 )
291 })
292 .map(|sub| crate::templates::FanPlusPaneView {
293 period_end: sub
294 .current_period_end
295 .map(|d| d.format("%b %-d, %Y").to_string()),
296 cancel_at_period_end: sub.cancel_at_period_end,
297 });
298
299 let csrf_token = crate::csrf::get_or_create_token(&session).await.ok();
300
301 let notifications = db::lists::notification_prefs(&db, session_user.id).await?;
302
303 Ok(UserAccountTabTemplate {
304 user,
305 notifications,
306 operational_mail: crate::templates::OperationalMailRow::all(),
307 sessions,
308 current_session_id,
309 can_create_projects: session_user.can_create_projects,
310 email_verified: db_user.email_verified,
311 moderation_active,
312 moderation_history,
313 creator_paused: db_user.is_creator_paused(),
314 fan_plus,
315 csrf_token,
316 })
317 }
318
319 /// Render the HTMX partial for the dashboard projects tab.
320 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_projects")]
321 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_projects(
322 State(db): State<PgPool>,
323 AuthUser(session_user): AuthUser,
324 headers: HeaderMap,
325 ) -> Result<axum::response::Response> {
326 let generation = db::users::get_cache_generation(&db, session_user.id).await?;
327 if let Some(not_modified) = helpers::check_etag(&headers, generation) {
328 return Ok(not_modified);
329 }
330
331 let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
332
333 let projects: Vec<ProjectCard> = db_projects.iter().map(ProjectCard::from_db).collect();
334
335 Ok(helpers::with_etag(
336 generation,
337 build_projects(projects, &session_user),
338 ))
339 }
340
341 /// The projects tab's contents, without the transport around them.
342 ///
343 /// Takes the cards rather than the pool: the dashboard page already builds this
344 /// exact vector for itself, so the described strip (`6b24f2df` step 5) fills its
345 /// shown panel without a second query.
346 pub(in crate::routes::pages::dashboard) fn build_projects(
347 projects: Vec<ProjectCard>,
348 session_user: &crate::auth::SessionUser,
349 ) -> UserProjectsTabTemplate {
350 UserProjectsTabTemplate {
351 projects,
352 can_create_projects: session_user.can_create_projects,
353 }
354 }
355
356 /// Support tab; submit a support ticket.
357 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_support")]
358 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_support(
359 AuthUser(session_user): AuthUser,
360 ) -> Result<impl IntoResponse> {
361 Ok(build_support(&session_user))
362 }
363
364 /// The support tab's contents. A deactivated account opens on this one, so the
365 /// dashboard page renders it rather than fetching it (`6b24f2df` step 5).
366 pub(in crate::routes::pages::dashboard) fn build_support(
367 session_user: &crate::auth::SessionUser,
368 ) -> UserSupportTabTemplate {
369 UserSupportTabTemplate {
370 email: session_user.email.clone(),
371 }
372 }
373
374 /// SSH Keys tab; manage SSH keys for git access.
375 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_ssh_keys")]
376 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_ssh_keys(
377 State(db): State<PgPool>,
378 AuthUser(session_user): AuthUser,
379 ) -> Result<impl IntoResponse> {
380 build_ssh_keys(&db, &session_user).await
381 }
382
383 /// The SSH-keys section's contents, without the transport around them.
384 ///
385 /// Fillable from the settings tab (`3a7de032`) so "Manage SSH Keys" on the
386 /// project code tab lands on the section rather than on Profile. This is the
387 /// Askama rendering; when `QUASI_SCREENS` names `crate::quasi::ssh_keys` that
388 /// screen answers the address instead and the section is fetched on a press,
389 /// which is what `settings_tabs::shown_at` checks the switch for.
390 async fn build_ssh_keys(
391 db: &PgPool,
392 session_user: &crate::auth::SessionUser,
393 ) -> Result<UserSshKeysTabTemplate> {
394 let db_user = db::users::get_user_by_id(db, session_user.id)
395 .await?
396 .ok_or(AppError::NotFound)?;
397
398 let username = session_user.username.to_string();
399 Ok(UserSshKeysTabTemplate {
400 username,
401 theme_options: crate::theming::console_theme_options(db_user.console_theme.as_deref()),
402 })
403 }
404