Skip to main content

max / makenotwork

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