Skip to main content

max / makenotwork

11.7 KB · 332 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 dashboard_tab_contacts, dashboard_tab_payments, dashboard_tab_payout_summary,
15 dashboard_transactions,
16 };
17
18 use axum::extract::State;
19 use axum::http::HeaderMap;
20 use axum::response::IntoResponse;
21 use tower_sessions::Session;
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 UserProjectsTabTemplate, UserSettingsTabTemplate, UserSshKeysTabTemplate,
32 UserSupportTabTemplate,
33 },
34 types::{ProjectCard, User},
35 };
36 use sqlx::PgPool;
37
38 /// Render the HTMX partial for the dashboard settings meta-tab.
39 /// Includes profile content inline; other sections loaded via HTMX sub-nav.
40 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_settings")]
41 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings(
42 State(db): State<PgPool>,
43 State(config): State<Config>,
44 AuthUser(session_user): AuthUser,
45 ) -> Result<impl IntoResponse> {
46 let db_user = db::users::get_user_by_id(&db, session_user.id)
47 .await?
48 .ok_or(AppError::NotFound)?;
49
50 let db_links = db::custom_links::get_custom_links_by_user(&db, session_user.id).await?;
51
52 let user = User::from(&db_user);
53
54 let custom_links: Vec<CustomLinkWithId> = db_links
55 .into_iter()
56 .map(|l| CustomLinkWithId {
57 id: l.id.to_string(),
58 url: l.url,
59 title: l.title,
60 })
61 .collect();
62
63 let feed_url = helpers::generate_feed_url(
64 &config.host_url,
65 session_user.id,
66 db_user.feed_key_version,
67 &config.signing_secret,
68 );
69
70 let custom_domain =
71 db::custom_domains::get_custom_domain_by_user(&db, session_user.id)
72 .await?
73 .map(|d| {
74 let instructions = if d.verified {
75 String::new()
76 } else {
77 format!(
78 "Point {0} at connect.makenot.work (CNAME, DNS-only) and add a TXT _mnw-verify.{0} with value {1}, then verify.",
79 d.domain, d.verification_token
80 )
81 };
82 crate::templates::CustomDomainInfo {
83 id: d.id.to_string(),
84 domain: d.domain,
85 verified: d.verified,
86 verification_token: d.verification_token,
87 instructions,
88 }
89 });
90
91 let has_media = session_user.can_create_projects;
92 let git_enabled = config.build.git_repos_path.is_some();
93 let has_mt_memberships = config.integrations.mt_base_url.is_some();
94
95 Ok(UserSettingsTabTemplate {
96 user,
97 custom_links,
98 feed_url,
99 can_create_projects: session_user.can_create_projects,
100 custom_domain,
101 has_media,
102 git_enabled,
103 has_mt_memberships,
104 theme_options: crate::theming::theme_options(db_user.theme_id.as_deref()),
105 })
106 }
107
108 /// Legacy route; redirects to the profile tab.
109 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_details(
110 db: State<PgPool>,
111 config: State<Config>,
112 session_user: AuthUser,
113 ) -> Result<impl IntoResponse> {
114 dashboard_tab_profile(db, config, session_user).await
115 }
116
117 /// Render the HTMX partial for the dashboard profile tab.
118 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_profile")]
119 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_profile(
120 State(db): State<PgPool>,
121 State(config): State<Config>,
122 AuthUser(session_user): AuthUser,
123 ) -> Result<impl IntoResponse> {
124 let db_user = db::users::get_user_by_id(&db, session_user.id)
125 .await?
126 .ok_or(AppError::NotFound)?;
127
128 let db_links = db::custom_links::get_custom_links_by_user(&db, session_user.id).await?;
129
130 let user = User::from(&db_user);
131
132 let custom_links: Vec<CustomLinkWithId> = db_links
133 .into_iter()
134 .map(|l| CustomLinkWithId {
135 id: l.id.to_string(),
136 url: l.url,
137 title: l.title,
138 })
139 .collect();
140
141 let feed_url = helpers::generate_feed_url(
142 &config.host_url,
143 session_user.id,
144 db_user.feed_key_version,
145 &config.signing_secret,
146 );
147
148 let custom_domain =
149 db::custom_domains::get_custom_domain_by_user(&db, session_user.id)
150 .await?
151 .map(|d| {
152 let instructions = if d.verified {
153 String::new()
154 } else {
155 format!(
156 "Point {0} at connect.makenot.work (CNAME, DNS-only) and add a TXT _mnw-verify.{0} with value {1}, then verify.",
157 d.domain, d.verification_token
158 )
159 };
160 crate::templates::CustomDomainInfo {
161 id: d.id.to_string(),
162 domain: d.domain,
163 verified: d.verified,
164 verification_token: d.verification_token,
165 instructions,
166 }
167 });
168
169 Ok(UserProfileTabTemplate {
170 user,
171 custom_links,
172 feed_url,
173 can_create_projects: session_user.can_create_projects,
174 custom_domain,
175 theme_options: crate::theming::theme_options(db_user.theme_id.as_deref()),
176 })
177 }
178
179 /// Regenerate the user's personal feed URL, revoking the previous one.
180 ///
181 /// Bumps `feed_key_version` (which is folded into the feed HMAC) and returns
182 /// the refreshed feed-row partial for HTMX to swap in. Any feed URL the user
183 /// had already shared stops verifying immediately.
184 #[tracing::instrument(skip_all, name = "dashboard_tabs::regenerate_feed_url")]
185 pub(in crate::routes::pages::dashboard) async fn regenerate_feed_url(
186 State(db): State<PgPool>,
187 State(config): State<Config>,
188 AuthUser(session_user): AuthUser,
189 ) -> Result<impl IntoResponse> {
190 let version = db::users::bump_feed_key_version(&db, session_user.id).await?;
191 let feed_url = helpers::generate_feed_url(
192 &config.host_url,
193 session_user.id,
194 version,
195 &config.signing_secret,
196 );
197
198 // host_url is config (https origin), the id is a UUID, version an integer,
199 // sig is hex, none can contain HTML metacharacters. Encode the `&` query
200 // separator so the value attribute is well-formed; readers decode it back.
201 let escaped = feed_url.replace('&', "&amp;");
202 Ok(axum::response::Html(format!(
203 "<div class=\"profile-feed-row\" id=\"feed-url-row\">\
204 <input type=\"text\" id=\"feed-url\" value=\"{escaped}\" readonly class=\"profile-feed-input\">\
205 <button class=\"btn-secondary nowrap\" type=\"button\" \
206 onclick=\"navigator.clipboard.writeText(document.getElementById('feed-url').value).then(() =&gt; {{ this.textContent='Copied!'; setTimeout(() =&gt; this.textContent='Copy URL', 2000) }})\">Copy URL</button>\
207 <button class=\"btn-secondary nowrap\" type=\"button\" \
208 hx-post=\"/dashboard/feed/regenerate\" hx-target=\"#feed-url-row\" hx-swap=\"outerHTML\">Regenerate</button>\
209 </div>"
210 )))
211 }
212
213 /// Render the HTMX partial for the dashboard account tab.
214 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_account")]
215 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
216 State(db): State<PgPool>,
217 session: Session,
218 AuthUser(session_user): AuthUser,
219 ) -> Result<impl IntoResponse> {
220 let db_user = db::users::get_user_by_id(&db, session_user.id)
221 .await?
222 .ok_or(AppError::NotFound)?;
223
224 let user = User::from(&db_user);
225
226 let sessions = db::sessions::get_user_sessions(&db, session_user.id).await?;
227 let current_session_id = session
228 .get::<db::UserSessionId>(SESSION_TRACKING_KEY)
229 .await
230 .ok()
231 .flatten();
232
233 // Fetch moderation actions for "Account Status" section
234 let active_actions = db::moderation::get_active_actions(&db, session_user.id).await?;
235 let all_actions = db::moderation::get_history(&db, session_user.id).await?;
236
237 let moderation_active: Vec<ModerationActionView> = active_actions
238 .iter()
239 .map(|a| ModerationActionView {
240 action_label: a.action_type.label().to_string(),
241 reason: a.reason.clone(),
242 created_at: a.created_at.format("%b %-d, %Y").to_string(),
243 resolved_at: None,
244 })
245 .collect();
246
247 let moderation_history: Vec<ModerationActionView> = all_actions
248 .iter()
249 .filter(|a| a.resolved_at.is_some())
250 .map(|a| ModerationActionView {
251 action_label: a.action_type.label().to_string(),
252 reason: a.reason.clone(),
253 created_at: a.created_at.format("%b %-d, %Y").to_string(),
254 resolved_at: a.resolved_at.map(|d| d.format("%b %-d, %Y").to_string()),
255 })
256 .collect();
257
258 let fan_plus = db::fan_plus::get_fan_plus_by_user(&db, session_user.id)
259 .await?
260 .filter(|sub| {
261 matches!(
262 sub.status,
263 db::SubscriptionStatus::Active | db::SubscriptionStatus::PastDue
264 )
265 })
266 .map(|sub| crate::templates::FanPlusPaneView {
267 period_end: sub
268 .current_period_end
269 .map(|d| d.format("%b %-d, %Y").to_string()),
270 cancel_at_period_end: sub.cancel_at_period_end,
271 });
272
273 let csrf_token = crate::csrf::get_or_create_token(&session).await.ok();
274
275 Ok(UserAccountTabTemplate {
276 user,
277 sessions,
278 current_session_id,
279 can_create_projects: session_user.can_create_projects,
280 email_verified: db_user.email_verified,
281 moderation_active,
282 moderation_history,
283 creator_paused: db_user.is_creator_paused(),
284 fan_plus,
285 csrf_token,
286 })
287 }
288
289 /// Render the HTMX partial for the dashboard projects tab.
290 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_projects")]
291 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_projects(
292 State(db): State<PgPool>,
293 AuthUser(session_user): AuthUser,
294 headers: HeaderMap,
295 ) -> Result<axum::response::Response> {
296 let generation = db::users::get_cache_generation(&db, session_user.id).await?;
297 if let Some(not_modified) = helpers::check_etag(&headers, generation) {
298 return Ok(not_modified);
299 }
300
301 let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
302
303 let projects: Vec<ProjectCard> = db_projects.iter().map(ProjectCard::from_db).collect();
304
305 Ok(helpers::with_etag(
306 generation,
307 UserProjectsTabTemplate {
308 projects,
309 can_create_projects: session_user.can_create_projects,
310 },
311 ))
312 }
313
314 /// Support tab; submit a support ticket.
315 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_support")]
316 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_support(
317 AuthUser(session_user): AuthUser,
318 ) -> Result<impl IntoResponse> {
319 Ok(UserSupportTabTemplate {
320 email: session_user.email.clone(),
321 })
322 }
323
324 /// SSH Keys tab; manage SSH keys for git access.
325 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_ssh_keys")]
326 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_ssh_keys(
327 AuthUser(session_user): AuthUser,
328 ) -> Result<impl IntoResponse> {
329 let username = session_user.username.to_string();
330 Ok(UserSshKeysTabTemplate { username })
331 }
332