Skip to main content

max / makenotwork

Clean up legacy details tab and stale references - Remove UserDetailsTabTemplate and user_details.html (replaced by user_profile.html and user_account.html) - Redirect /dashboard/tabs/details to profile handler - Fix onboarding checklist link: tab-details → tab-profile - Fix creators page link: #tab-creator → #tab-plan - Update HTMX tests to use /dashboard/tabs/profile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-05 18:00 UTC
Commit: bf66761baa0bd983551d72bb95e749c635371a33
Parent: 6e6eb06
7 files changed, +9 insertions, -540 deletions
@@ -32,8 +32,8 @@ fn build_onboarding_checklist(
32 32 OnboardingStep {
33 33 label: "Set up your profile — name, bio, and links",
34 34 done: profile_done,
35 - link_tab: "tab-details",
36 - link_label: "Go to Account",
35 + link_tab: "tab-profile",
36 + link_label: "Go to Profile",
37 37 },
38 38 OnboardingStep {
39 39 label: "Connect Stripe — required to receive payments, 3% processing only",
@@ -18,104 +18,12 @@ use crate::{
18 18
19 19 use super::super::AnalyticsQuery;
20 20
21 - /// Render the HTMX partial for the dashboard details tab.
22 - #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_details")]
21 + /// Legacy route — redirects to the profile tab.
23 22 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_details(
24 - State(state): State<AppState>,
25 - session: Session,
26 - AuthUser(session_user): AuthUser,
23 + state: State<AppState>,
24 + session_user: AuthUser,
27 25 ) -> Result<impl IntoResponse> {
28 - let db_user = db::users::get_user_by_id(&state.db, session_user.id)
29 - .await?
30 - .ok_or(AppError::NotFound)?;
31 -
32 - let db_links =
33 - db::custom_links::get_custom_links_by_user(&state.db, session_user.id).await?;
34 -
35 - let user = User::from(&db_user);
36 -
37 - let custom_links: Vec<CustomLinkWithId> = db_links
38 - .into_iter()
39 - .map(|l| CustomLinkWithId {
40 - id: l.id.to_string(),
41 - url: l.url,
42 - title: l.title,
43 - })
44 - .collect();
45 -
46 - let feed_url = helpers::generate_feed_url(
47 - &state.config.host_url,
48 - session_user.id,
49 - &state.config.signing_secret,
50 - );
51 -
52 - let sessions =
53 - db::sessions::get_user_sessions(&state.db, session_user.id).await?;
54 - let current_session_id = session
55 - .get::<db::UserSessionId>(SESSION_TRACKING_KEY)
56 - .await
57 - .ok()
58 - .flatten();
59 -
60 - let custom_domain =
61 - db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
62 - .await?
63 - .map(|d| {
64 - let instructions = if d.verified {
65 - String::new()
66 - } else {
67 - format!(
68 - "Add a DNS TXT record: _mnw-verify.{} with value {}",
69 - d.domain, d.verification_token
70 - )
71 - };
72 - crate::templates::CustomDomainInfo {
73 - id: d.id.to_string(),
74 - domain: d.domain,
75 - verified: d.verified,
76 - verification_token: d.verification_token,
77 - instructions,
78 - }
79 - });
80 -
81 - // Fetch moderation actions for "Account Status" section
82 - let active_actions = db::moderation::get_active_actions(&state.db, session_user.id).await?;
83 - let all_actions = db::moderation::get_history(&state.db, session_user.id).await?;
84 -
85 - let moderation_active: Vec<ModerationActionView> = active_actions
86 - .iter()
87 - .map(|a| ModerationActionView {
88 - action_label: a.action_type.label().to_string(),
89 - reason: a.reason.clone(),
90 - created_at: a.created_at.format("%b %-d, %Y").to_string(),
91 - resolved_at: None,
92 - })
93 - .collect();
94 -
95 - let moderation_history: Vec<ModerationActionView> = all_actions
96 - .iter()
97 - .filter(|a| a.resolved_at.is_some())
98 - .map(|a| ModerationActionView {
99 - action_label: a.action_type.label().to_string(),
100 - reason: a.reason.clone(),
101 - created_at: a.created_at.format("%b %-d, %Y").to_string(),
102 - resolved_at: a.resolved_at.map(|d| d.format("%b %-d, %Y").to_string()),
103 - })
104 - .collect();
105 -
106 - Ok(UserDetailsTabTemplate {
107 - email_verified: db_user.email_verified,
108 - user,
109 - custom_links,
110 - feed_url,
111 - sessions,
112 - current_session_id,
113 - can_create_projects: session_user.can_create_projects,
114 - custom_domain,
115 - moderation_active,
116 - moderation_history,
117 - creator_paused: db_user.is_creator_paused(),
118 - })
26 + dashboard_tab_profile(state, session_user).await
119 27 }
120 28
121 29 /// Render the HTMX partial for the dashboard profile tab.
@@ -116,7 +116,6 @@ impl_into_response!(
116 116 ExportDownloadTemplate,
117 117 ExportContentReadyTemplate,
118 118 TransactionsTableTemplate,
119 - UserDetailsTabTemplate,
120 119 UserProfileTabTemplate,
121 120 UserAccountTabTemplate,
122 121 UserSshKeysTabTemplate,
@@ -190,30 +190,6 @@ pub struct UserAccountTabTemplate {
190 190 pub creator_paused: bool,
191 191 }
192 192
193 - /// Legacy alias — kept temporarily during migration to avoid breaking existing route.
194 - #[derive(Template)]
195 - #[template(path = "partials/tabs/user_details.html")]
196 - pub struct UserDetailsTabTemplate {
197 - pub user: User,
198 - pub custom_links: Vec<CustomLinkWithId>,
199 - /// HMAC-signed personal RSS feed URL for this user.
200 - pub feed_url: String,
201 - pub sessions: Vec<DbUserSession>,
202 - pub current_session_id: Option<UserSessionId>,
203 - /// Whether this user has creator access (controls which prefs are shown).
204 - pub can_create_projects: bool,
205 - /// Whether the user's email address has been verified.
206 - pub email_verified: bool,
207 - /// The user's custom domain, if configured.
208 - pub custom_domain: Option<CustomDomainInfo>,
209 - /// Active (unresolved) moderation actions for the "Account Status" section.
210 - pub moderation_active: Vec<ModerationActionView>,
211 - /// Resolved moderation history (collapsed by default).
212 - pub moderation_history: Vec<ModerationActionView>,
213 - /// Whether this creator has voluntarily paused their account.
214 - pub creator_paused: bool,
215 - }
216 -
217 193 /// View model for a moderation action displayed on the settings page.
218 194 pub struct ModerationActionView {
219 195 /// Human-readable label (e.g., "Warning", "Content Removal", "Suspension")
@@ -127,7 +127,7 @@
127 127 {% else %}
128 128 {% if let Some(user) = session_user %}
129 129 <p>Ready to create?</p>
130 - <a href="/dashboard#tab-creator"><button class="primary">Apply from Dashboard</button></a>
130 + <a href="/dashboard#tab-plan"><button class="primary">Apply from Dashboard</button></a>
131 131 {% else %}
132 132 <p>Join to get started.</p>
133 133 <a href="/join"><button class="primary">Join</button></a>
@@ -1,414 +0,0 @@
1 - <div class="tab-docs"><a href="/docs/profile">Docs: Profile &rarr;</a></div>
2 -
3 - <div class="form-section">
4 - <h2>Links</h2>
5 - <p class="muted" style="margin-bottom: 1rem;">Add links to your public profile.</p>
6 -
7 - <div id="links-list">
8 - {% for link in custom_links %}
9 - <div class="link-row" data-id="{{ link.id }}">
10 - <div class="link-order-buttons">
11 - <button type="button" class="order-btn" onclick="moveLink(this, -1)" title="Move up">&#9650;</button>
12 - <button type="button" class="order-btn" onclick="moveLink(this, 1)" title="Move down">&#9660;</button>
13 - </div>
14 - <input type="text" value="{{ link.title }}" placeholder="Title" name="title" disabled>
15 - <input type="url" value="{{ link.url }}" placeholder="https://..." name="url" disabled>
16 - <button class="secondary link-edit-btn" onclick="editLink(this)">Edit</button>
17 - <button class="primary link-save-btn" style="display:none" onclick="saveLink(this)">Save</button>
18 - <button class="secondary link-cancel-btn" style="display:none" onclick="cancelLink(this)">Cancel</button>
19 - <button class="danger link-remove-btn"
20 - hx-delete="/api/links/{{ link.id }}"
21 - hx-target="closest .link-row"
22 - hx-swap="outerHTML"
23 - hx-confirm="Remove this link?">Remove</button>
24 - </div>
25 - {% endfor %}
26 - </div>
27 -
28 - <script>
29 - function moveLink(btn, direction) {
30 - const row = btn.closest('.link-row');
31 - const list = document.getElementById('links-list');
32 - const rows = Array.from(list.querySelectorAll('.link-row'));
33 - const index = rows.indexOf(row);
34 -
35 - if (direction === -1 && index > 0) {
36 - list.insertBefore(row, rows[index - 1]);
37 - } else if (direction === 1 && index < rows.length - 1) {
38 - list.insertBefore(rows[index + 1], row);
39 - } else {
40 - return; // Already at boundary
41 - }
42 -
43 - // Send new order to server
44 - const linkIds = Array.from(list.querySelectorAll('.link-row')).map(r => r.dataset.id);
45 - fetch('/api/links/reorder', {
46 - method: 'PUT',
47 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
48 - body: JSON.stringify({ link_ids: linkIds })
49 - }).catch(function() {});
50 - }
51 -
52 - function editLink(btn) {
53 - var row = btn.closest('.link-row');
54 - row.querySelectorAll('input').forEach(function(i) { i.disabled = false; });
55 - row.querySelector('.link-edit-btn').style.display = 'none';
56 - row.querySelector('.link-remove-btn').style.display = 'none';
57 - row.querySelector('.link-save-btn').style.display = '';
58 - row.querySelector('.link-cancel-btn').style.display = '';
59 - row.dataset.origTitle = row.querySelector('[name=title]').value;
60 - row.dataset.origUrl = row.querySelector('[name=url]').value;
61 - }
62 -
63 - function cancelLink(btn) {
64 - var row = btn.closest('.link-row');
65 - row.querySelector('[name=title]').value = row.dataset.origTitle;
66 - row.querySelector('[name=url]').value = row.dataset.origUrl;
67 - row.querySelectorAll('input').forEach(function(i) { i.disabled = true; });
68 - row.querySelector('.link-edit-btn').style.display = '';
69 - row.querySelector('.link-remove-btn').style.display = '';
70 - row.querySelector('.link-save-btn').style.display = 'none';
71 - row.querySelector('.link-cancel-btn').style.display = 'none';
72 - }
73 -
74 - function saveLink(btn) {
75 - var row = btn.closest('.link-row');
76 - var id = row.dataset.id;
77 - fetch('/api/links/' + id, {
78 - method: 'PUT',
79 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
80 - body: JSON.stringify({
81 - title: row.querySelector('[name=title]').value,
82 - url: row.querySelector('[name=url]').value
83 - })
84 - }).then(function(res) {
85 - if (!res.ok) throw new Error('Failed to save');
86 - cancelLink(btn);
87 - }).catch(function() {
88 - var saveBtn = row.querySelector('.link-save-btn');
89 - if (saveBtn) saveBtn.textContent = 'Save failed — retry';
90 - });
91 - }
92 - </script>
93 -
94 - <form hx-post="/api/links"
95 - hx-target="#links-list"
96 - hx-swap="beforeend"
97 - hx-on::after-request="if(event.detail.successful) this.reset()">
98 - <div style="display: flex; gap: 0.75rem; align-items: flex-end;">
99 - <div class="form-group" style="flex: 1; margin-bottom: 0;">
100 - <label for="link-title">Title</label>
101 - <input type="text" id="link-title" name="title" placeholder="My Website" required>
102 - </div>
103 - <div class="form-group" style="flex: 2; margin-bottom: 0;">
104 - <label for="link-url">URL</label>
105 - <input type="url" id="link-url" name="url" placeholder="https://..." required>
106 - </div>
107 - <button class="secondary" type="submit" style="margin-bottom: 0;">Add Link</button>
108 - </div>
109 - </form>
110 - </div>
111 -
112 - <div class="form-section">
113 - <h2>Profile</h2>
114 - <p class="muted" style="margin-bottom: 1rem;">Your public page: <a href="/u/{{ user.username }}">makenot.work/u/{{ user.username }}</a></p>
115 -
116 - <form hx-put="/api/users/me"
117 - hx-target="#profile-save-status"
118 - hx-swap="innerHTML"
119 - hx-indicator="#profile-spinner">
120 - <div class="form-group">
121 - <label for="display-name">Display Name</label>
122 - <input type="text" id="display-name" name="display_name" value="{{ user.display_name_or_username() }}">
123 - <div class="hint">Defaults to your username if left empty</div>
124 - </div>
125 -
126 - <div class="form-group">
127 - <label for="bio">Bio</label>
128 - <textarea id="bio" name="bio">{{ user.bio.as_deref().unwrap_or("") }}</textarea>
129 - </div>
130 -
131 - <button class="primary" type="submit">
132 - Save Changes
133 - <span id="profile-spinner" class="htmx-indicator"> ...</span>
134 - </button>
135 - <span id="profile-save-status"></span>
136 - </form>
137 - </div>
138 -
139 - {% if can_create_projects %}
140 - <details class="form-section">
141 - <summary><h2>Custom Domain</h2></summary>
142 - <p class="muted" style="margin-bottom: 1rem;">Point your own domain at your makenot.work profile.</p>
143 -
144 - {% match custom_domain %}
145 - {% when Some with (cd) %}
146 - <div style="margin-bottom: 1rem;">
147 - <strong>{{ cd.domain }}</strong>
148 - {% if cd.verified %}
149 - <span style="color: var(--success-color); margin-left: 0.5rem;">Verified</span>
150 - {% else %}
151 - <span style="color: var(--warning-color); margin-left: 0.5rem;">Pending verification</span>
152 - {% endif %}
153 - </div>
154 -
155 - {% if !cd.verified %}
156 - <div style="background: var(--warning-bg); border: 1px solid var(--warning-border); padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem;">
157 - <p style="margin: 0 0 0.5rem 0;"><strong>DNS setup required:</strong></p>
158 - <p style="margin: 0; font-family: monospace; font-size: 0.85rem;">
159 - TXT record: <code>_mnw-verify.{{ cd.domain }}</code><br>
160 - Value: <code>{{ cd.verification_token }}</code>
161 - </p>
162 - </div>
163 - <div style="display: flex; gap: 0.75rem;">
164 - <button class="primary"
165 - hx-post="/api/domains/verify"
166 - hx-vals='{"domain_id": "{{ cd.id }}"}'
167 - hx-target="#domain-verify-result"
168 - hx-swap="innerHTML">Check DNS</button>
169 - <button class="danger"
170 - hx-delete="/api/domains/{{ cd.id }}"
171 - hx-confirm="Remove this domain?"
172 - hx-target="closest .form-section"
173 - hx-swap="outerHTML"
174 - hx-on::after-request="if(event.detail.successful) location.reload()">Remove</button>
175 - </div>
176 - <div id="domain-verify-result" style="margin-top: 0.75rem;"></div>
177 - {% else %}
178 - <p style="font-size: 0.9rem; opacity: 0.8;">
179 - Visitors to <strong>{{ cd.domain }}</strong> will see your profile, projects, and items.
180 - </p>
181 - <button class="danger"
182 - hx-delete="/api/domains/{{ cd.id }}"
183 - hx-confirm="Remove this domain? Your domain will stop serving your content."
184 - hx-target="closest .form-section"
185 - hx-swap="outerHTML"
186 - hx-on::after-request="if(event.detail.successful) location.reload()">Remove Domain</button>
187 - {% endif %}
188 - {% when None %}
189 - <form hx-post="/api/domains"
190 - hx-target="#domain-add-result"
191 - hx-swap="innerHTML">
192 - <div style="display: flex; gap: 0.75rem; align-items: flex-end;">
193 - <div class="form-group" style="flex: 1; margin-bottom: 0;">
194 - <label for="custom-domain">Domain</label>
195 - <input type="text" id="custom-domain" name="domain" placeholder="mysite.com" required>
196 - </div>
197 - <button class="primary" type="submit" style="margin-bottom: 0;">Add Domain</button>
198 - </div>
199 - </form>
200 - <div id="domain-add-result" style="margin-top: 0.75rem;"></div>
201 - {% endmatch %}
202 - </details>
203 - {% endif %}
204 -
205 - <details class="form-section">
206 - <summary><h2>Personal Feed</h2></summary>
207 - <p class="muted" style="margin-bottom: 0.75rem;">An RSS feed of new content from creators and projects you follow. Add this URL to your RSS reader.</p>
208 - <div style="display: flex; gap: 0.5rem; align-items: center;">
209 - <input type="text" id="feed-url" value="{{ feed_url }}" readonly style="flex: 1; font-size: 0.85rem; font-family: monospace; opacity: 0.8;">
210 - <button class="secondary" type="button" style="white-space: nowrap;"
211 - onclick="navigator.clipboard.writeText(document.getElementById('feed-url').value).then(() => { this.textContent='Copied!'; setTimeout(() => this.textContent='Copy URL', 2000) })">Copy URL</button>
212 - </div>
213 - </details>
214 -
215 - <div class="form-section">
216 - <h2>Account</h2>
217 - {% if !email_verified %}
218 - <div style="background: var(--warning-bg); border: 1px solid var(--warning-border); padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem;">
219 - <strong>Email not verified.</strong> Some features require a verified email.
220 - <button class="secondary small" style="margin-left: 0.5rem;"
221 - hx-post="/api/resend-verification"
222 - hx-target="#verify-nudge-result"
223 - hx-swap="innerHTML">Resend verification email</button>
224 - <span id="verify-nudge-result"></span>
225 - </div>
226 - {% endif %}
227 - <div class="form-group">
228 - <label for="email">Email</label>
229 - <input type="email" id="email" value="{{ user.email }}" disabled>
230 - <div class="hint">Contact support to update your email address</div>
231 - </div>
232 - <div class="form-group">
233 - <label for="username">Username</label>
234 - <input type="text" id="username" value="{{ user.username }}" disabled>
235 - <div class="hint">Username cannot be changed</div>
236 - </div>
237 - </div>
238 -
239 - <div class="form-section">
240 - <h2>Account Status</h2>
241 - {% if moderation_active.is_empty() %}
242 - <p style="font-size: 0.9rem; opacity: 0.7;">Your account is in good standing.</p>
243 - {% else %}
244 - {% for action in moderation_active %}
245 - <div style="background: var(--danger-bg); border: 1px solid var(--danger); padding: 0.75rem 1rem; margin-bottom: 0.75rem; font-size: 0.9rem;">
246 - <strong style="text-transform: capitalize;">{{ action.action_label }}</strong>
247 - <span style="opacity: 0.6; margin-left: 0.5rem;">{{ action.created_at }}</span>
248 - <p style="margin: 0.5rem 0 0 0;">{{ action.reason }}</p>
249 - </div>
250 - {% endfor %}
251 - <p style="font-size: 0.85rem; margin-top: 0.5rem;">
252 - If you believe an action was taken in error, you can <a href="/docs/appeals">submit an appeal</a> or contact <a href="mailto:support@makenot.work">support@makenot.work</a>.
253 - </p>
254 - {% endif %}
255 - {% if !moderation_history.is_empty() %}
256 - <details style="margin-top: 0.75rem;">
257 - <summary style="cursor: pointer; font-size: 0.85rem; opacity: 0.7;">View past actions</summary>
258 - <div style="margin-top: 0.5rem;">
259 - {% for action in moderation_history %}
260 - <div style="padding: 0.5rem 0; border-bottom: 1px solid var(--border-color); font-size: 0.85rem;">
261 - <strong style="text-transform: capitalize;">{{ action.action_label }}</strong>
262 - <span style="opacity: 0.6; margin-left: 0.5rem;">{{ action.created_at }}</span>
263 - {% if let Some(resolved) = action.resolved_at %}
264 - <span style="opacity: 0.6; margin-left: 0.5rem;">Resolved {{ resolved }}</span>
265 - {% endif %}
266 - <p style="margin: 0.25rem 0 0 0; opacity: 0.8;">{{ action.reason }}</p>
267 - </div>
268 - {% endfor %}
269 - </div>
270 - </details>
271 - {% endif %}
272 - </div>
273 -
274 - <details class="form-section">
275 - <summary><h2>Change Password</h2></summary>
276 - <form hx-put="/api/users/me/password"
277 - hx-target="#password-status"
278 - hx-swap="innerHTML"
279 - hx-indicator="#password-spinner"
280 - hx-on::after-request="if(event.detail.successful) this.reset()"
281 - onsubmit="var np=document.getElementById('new-password').value,cp=document.getElementById('confirm-password').value;if(np!==cp){document.getElementById('password-status').textContent='Passwords do not match';document.getElementById('password-status').style.color='var(--error-color)';return false;}">
282 - <div class="form-group">
283 - <label for="current-password">Current Password</label>
284 - <input type="password" id="current-password" name="current_password" required>
285 - </div>
286 - <div class="form-group">
287 - <label for="new-password">New Password</label>
288 - <input type="password" id="new-password" name="new_password" minlength="8" required>
289 - </div>
290 - <div class="form-group">
291 - <label for="confirm-password">Confirm New Password</label>
292 - <input type="password" id="confirm-password" name="confirm_password" required>
293 - </div>
294 - <button class="primary" type="submit">
295 - Update Password
296 - <span id="password-spinner" class="htmx-indicator"> ...</span>
297 - </button>
298 - <span id="password-status"></span>
299 - </form>
300 - </details>
301 -
302 - <details class="form-section">
303 - <summary><h2>Two-Factor Authentication</h2></summary>
304 - <div id="totp-section"
305 - hx-get="/api/users/me/totp/status"
306 - hx-trigger="revealed"
307 - hx-swap="innerHTML">
308 - <p style="opacity: 0.7;">Loading...</p>
309 - </div>
310 - </details>
311 -
312 - <details class="form-section">
313 - <summary><h2>Passkeys</h2></summary>
314 - <div id="passkey-section"
315 - hx-get="/api/users/me/passkeys"
316 - hx-trigger="revealed"
317 - hx-swap="innerHTML">
318 - <p style="opacity: 0.7;">Loading...</p>
319 - </div>
320 - </details>
321 -
322 - <details class="form-section">
323 - <summary><h2>Notification Preferences</h2></summary>
324 - <form hx-put="/api/users/me/preferences" hx-target="#preferences-result" hx-swap="innerHTML">
325 - {% if can_create_projects %}
326 - <div class="checkbox-group">
327 - <input type="checkbox" id="notify-sale" name="notify_sale" value="on"
328 - {% if user.notify_sale %}checked{% endif %}>
329 - <label for="notify-sale">Email me when someone buys my content</label>
330 - </div>
331 - <div class="checkbox-group">
332 - <input type="checkbox" id="notify-follower" name="notify_follower" value="on"
333 - {% if user.notify_follower %}checked{% endif %}>
334 - <label for="notify-follower">Email me when I get a new follower</label>
335 - </div>
336 - {% endif %}
337 - <div class="checkbox-group">
338 - <input type="checkbox" id="notify-release" name="notify_release" value="on"
339 - {% if user.notify_release %}checked{% endif %}>
340 - <label for="notify-release">Email me when creators I follow publish new content</label>
341 - </div>
342 - <div class="checkbox-group">
343 - <input type="checkbox" id="login-notification" name="login_notification_enabled" value="on"
344 - {% if user.login_notification_enabled %}checked{% endif %}>
345 - <label for="login-notification">Email me when a new device signs into my account</label>
346 - </div>
347 - {% if can_create_projects %}
348 - <div class="checkbox-group">
349 - <input type="checkbox" id="notify-issues" name="notify_issues" value="on"
350 - {% if user.notify_issues %}checked{% endif %}>
351 - <label for="notify-issues">Email me about new issues and comments on my repos</label>
352 - </div>
353 - <div class="checkbox-group" style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);">
354 - <input type="checkbox" id="tips-enabled" name="tips_enabled" value="on"
355 - {% if user.tips_enabled %}checked{% endif %}>
356 - <label for="tips-enabled">Accept tips on my profile and project pages</label>
357 - </div>
358 - <div class="checkbox-group">
359 - <input type="checkbox" id="notify-tip" name="notify_tip" value="on"
360 - {% if user.notify_tip %}checked{% endif %}>
361 - <label for="notify-tip">Email me when I receive a tip</label>
362 - </div>
363 - {% endif %}
364 - <div class="checkbox-group" style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);">
365 - <input type="checkbox" id="notify-status" name="notify_status" value="on"
366 - {% if user.notify_status %}checked{% endif %}>
367 - <label for="notify-status">Email me when platform status changes (outages, recovery)</label>
368 - </div>
369 - <div id="preferences-result"></div>
370 - <button type="submit" class="primary" style="margin-top: 1rem;">Save Preferences</button>
371 - </form>
372 - </details>
373 -
374 - <details class="form-section">
375 - <summary><h2>Active Sessions</h2></summary>
376 - {% include "partials/tabs/user_sessions.html" %}
377 - </details>
378 -
379 - <div class="form-section">
380 - <h2>Your Data</h2>
381 - <p class="muted" style="margin-bottom: 1rem; text-align: left;">
382 - Download all your projects, items, sales, and purchases.
383 - </p>
384 - <a href="/dashboard/export"><button class="secondary">Export Portal</button></a>
385 - <a href="/dashboard/import" style="margin-left: 0.5rem;"><button class="secondary">Import Data</button></a>
386 - </div>
387 -
388 - {% if can_create_projects && !creator_paused %}
389 - <details class="form-section">
390 - <summary><h2>Pause Creator Account</h2></summary>
391 - <p class="muted" style="margin-bottom: 1rem; text-align: left;">
392 - Pause your creator account to stop paying your subscription. Existing fan subscriptions will expire at the end of their billing period. One-time purchases remain accessible. Your content stays hosted indefinitely. You can resume at any time by re-subscribing to a tier.
393 - </p>
394 - <button class="danger"
395 - hx-post="/api/users/me/pause-creator"
396 - hx-confirm="Pause your creator account? Your subscription will be canceled and no new sales can be made. Existing fan subscriptions will expire at the end of their current billing period."
397 - hx-on::after-request="if(event.detail.successful) window.location.reload()">
398 - Pause Creator Account
399 - </button>
400 - </details>
401 - {% endif %}
402 -
403 - <details class="form-section">
404 - <summary><h2>Danger Zone</h2></summary>
405 - <p class="muted" style="margin-bottom: 1rem; text-align: left;">
406 - Deleting your account is permanent. All your projects will remain accessible, but you won't be able to manage them.
407 - </p>
408 - <button class="danger"
409 - hx-delete="/api/users/me"
410 - hx-confirm="Delete your account? This cannot be undone."
411 - hx-on::after-request="if(event.detail.successful) window.location.href='/login'">
412 - Delete Account
413 - </button>
414 - </details>
@@ -79,7 +79,7 @@ async fn dashboard_tab_without_htmx_returns_full_page_or_error() {
79 79 // The tab handlers are plain GET routes that return template partials.
80 80 // Without HTMX, they still return 200 with the partial -- this is expected
81 81 // since the tab routes don't check is_htmx_request themselves.
82 - let resp = h.client.get("/dashboard/tabs/details").await;
82 + let resp = h.client.get("/dashboard/tabs/profile").await;
83 83 assert_eq!(
84 84 resp.status, 200,
85 85 "Tab route should still respond to regular GET, got {}",
@@ -95,7 +95,7 @@ async fn dashboard_requires_auth() {
95 95 // Need to establish a session first for CSRF
96 96 h.client.fetch_csrf_token().await;
97 97
98 - let resp = h.client.htmx_get("/dashboard/tabs/details").await;
98 + let resp = h.client.htmx_get("/dashboard/tabs/profile").await;
99 99 assert_eq!(
100 100 resp.status, 401,
101 101 "Unauthenticated HTMX tab request should return 401, got {}",