Skip to main content

max / makenotwork

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