Skip to main content

max / makenotwork

v0.3.14: PWYW/free button support, remember-me, dashboard + form fixes Item page: pricing-aware buy button (PWYW/free/fixed), conditional media block. Project page: PWYW button variant. Dashboard: bundle section fix, project live link. Forms: collections/domains/subscriptions use Form extractor, CSRF headers on insertion JS, password confirmation validation. Login: remember-me checkbox with session expiry. Project settings: fix tab URL paths, JS-based save. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 03:21 UTC
Commit: 3c3bdd748b4ee4cc987cc5caff6b04bcc6d319ba
Parent: cc04eb7
14 files changed, +108 insertions, -47 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.13"
3 + version = "0.3.14"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -4,7 +4,7 @@ use axum::{
4 4 extract::{Path, State},
5 5 http::StatusCode,
6 6 response::IntoResponse,
7 - Json,
7 + Form, Json,
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 10
@@ -32,6 +32,7 @@ pub struct CreateCollectionRequest {
32 32 pub struct UpdateCollectionRequest {
33 33 pub title: String,
34 34 pub description: Option<String>,
35 + #[serde(default)]
35 36 pub is_public: bool,
36 37 }
37 38
@@ -82,7 +83,7 @@ async fn verify_collection_ownership(
82 83 pub(super) async fn create_collection(
83 84 State(state): State<AppState>,
84 85 AuthUser(user): AuthUser,
85 - Json(req): Json<CreateCollectionRequest>,
86 + Form(req): Form<CreateCollectionRequest>,
86 87 ) -> Result<impl IntoResponse> {
87 88 user.check_not_suspended()?;
88 89
@@ -144,7 +145,7 @@ pub(super) async fn update_collection(
144 145 State(state): State<AppState>,
145 146 AuthUser(user): AuthUser,
146 147 Path(id): Path<CollectionId>,
147 - Json(req): Json<UpdateCollectionRequest>,
148 + Form(req): Form<UpdateCollectionRequest>,
148 149 ) -> Result<impl IntoResponse> {
149 150 user.check_not_suspended()?;
150 151 verify_collection_ownership(&state, id, user.id).await?;
@@ -3,7 +3,7 @@
3 3 use axum::{
4 4 extract::{Path, Query, State},
5 5 response::IntoResponse,
6 - Json,
6 + Form, Json,
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 9 use serde_json::json;
@@ -34,7 +34,7 @@ struct DomainResponse {
34 34 pub(super) async fn add_domain(
35 35 State(state): State<AppState>,
36 36 AuthUser(session_user): AuthUser,
37 - Json(req): Json<AddDomainRequest>,
37 + Form(req): Form<AddDomainRequest>,
38 38 ) -> Result<impl IntoResponse> {
39 39 let domain = normalize_domain(&req.domain)?;
40 40 validate_domain(&domain)?;
@@ -69,7 +69,7 @@ pub(super) struct VerifyDomainRequest {
69 69 pub(super) async fn verify_domain(
70 70 State(state): State<AppState>,
71 71 AuthUser(session_user): AuthUser,
72 - Json(req): Json<VerifyDomainRequest>,
72 + Form(req): Form<VerifyDomainRequest>,
73 73 ) -> Result<impl IntoResponse> {
74 74 let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
75 75 .await?
@@ -4,7 +4,7 @@ use axum::{
4 4 extract::{Path, State},
5 5 http::{header::HeaderMap, StatusCode},
6 6 response::{IntoResponse, Response},
7 - Json,
7 + Form, Json,
8 8 };
9 9 use serde::{Deserialize, Serialize};
10 10
@@ -43,6 +43,7 @@ pub(super) struct CreateTierRequest {
43 43 pub(super) struct UpdateTierRequest {
44 44 pub name: String,
45 45 pub description: Option<String>,
46 + #[serde(default)]
46 47 pub is_active: bool,
47 48 }
48 49
@@ -52,7 +53,7 @@ pub(super) async fn create_tier(
52 53 State(state): State<AppState>,
53 54 AuthUser(user): AuthUser,
54 55 Path(project_id): Path<ProjectId>,
55 - Json(req): Json<CreateTierRequest>,
56 + Form(req): Form<CreateTierRequest>,
56 57 ) -> Result<impl IntoResponse> {
57 58 user.check_not_suspended()?;
58 59 // Verify ownership
@@ -145,7 +146,7 @@ pub(super) async fn update_tier(
145 146 State(state): State<AppState>,
146 147 AuthUser(user): AuthUser,
147 148 Path(tier_id): Path<SubscriptionTierId>,
148 - Json(req): Json<UpdateTierRequest>,
149 + Form(req): Form<UpdateTierRequest>,
149 150 ) -> Result<impl IntoResponse> {
150 151 user.check_not_suspended()?;
151 152 // Get tier and verify project ownership
@@ -9,7 +9,7 @@ use axum::{
9 9 };
10 10 use serde::Deserialize;
11 11 use tower_governor::GovernorLayer;
12 - use tower_sessions::Session;
12 + use tower_sessions::{Expiry, Session};
13 13
14 14 use crate::{
15 15 auth::{login_user, logout_user, track_session, verify_password, SessionUser, SESSION_TRACKING_KEY},
@@ -53,6 +53,8 @@ pub fn auth_routes() -> Router<AppState> {
53 53 pub struct LoginForm {
54 54 pub login: String, // Can be username or email
55 55 pub password: String,
56 + #[serde(default)]
57 + pub remember_me: Option<String>,
56 58 }
57 59
58 60 /// Authenticate a user via username/email and password with lockout protection.
@@ -146,6 +148,8 @@ async fn login_handler(
146 148 // Successful login - reset failed attempts
147 149 db::auth::reset_failed_login(&state.db, user.id).await?;
148 150
151 + let remember = form.remember_me.as_deref() == Some("on");
152 +
149 153 // Check if user has 2FA enabled — redirect to verification page if so
150 154 if user.totp_enabled {
151 155 session.insert("pending_2fa_user_id", user.id).await
@@ -156,6 +160,8 @@ async fn login_handler(
156 160 .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
157 161 session.insert("pending_2fa_notify_name", &user.display_name).await
158 162 .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
163 + session.insert("pending_2fa_remember_me", remember).await
164 + .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?;
159 165
160 166 tracing::info!(user_id = %user.id, event = "login_2fa_pending", "User requires 2FA verification");
161 167
@@ -190,6 +196,9 @@ async fn login_handler(
190 196 };
191 197
192 198 login_user(&session, session_user).await?;
199 + if !remember {
200 + session.set_expiry(Some(Expiry::OnSessionEnd));
201 + }
193 202 track_session(&session, &state.db, user_id, &headers).await?;
194 203 tracing::info!(user_id = %user_id, event = "login_success", "User logged in");
195 204
@@ -7,7 +7,7 @@ use axum::{
7 7 Form,
8 8 };
9 9 use serde::Deserialize;
10 - use tower_sessions::Session;
10 + use tower_sessions::{Expiry, Session};
11 11
12 12 use crate::{
13 13 auth::{login_user, track_session, SessionUser},
@@ -119,11 +119,20 @@ pub(super) async fn verify_two_factor(
119 119 .ok()
120 120 .flatten();
121 121
122 + // Retrieve remember-me preference
123 + let remember: bool = session
124 + .get("pending_2fa_remember_me")
125 + .await
126 + .ok()
127 + .flatten()
128 + .unwrap_or(false);
129 +
122 130 // Clear pending 2FA state
123 131 session.remove::<UserId>(PENDING_2FA_KEY).await.ok();
124 132 session.remove::<bool>(PENDING_2FA_NOTIFY_ENABLED).await.ok();
125 133 session.remove::<String>(PENDING_2FA_NOTIFY_EMAIL).await.ok();
126 134 session.remove::<String>(PENDING_2FA_NOTIFY_NAME).await.ok();
135 + session.remove::<bool>("pending_2fa_remember_me").await.ok();
127 136
128 137 // Complete login
129 138 let suspended = user.is_suspended();
@@ -144,6 +153,9 @@ pub(super) async fn verify_two_factor(
144 153 };
145 154
146 155 login_user(&session, session_user).await?;
156 + if !remember {
157 + session.set_expiry(Some(Expiry::OnSessionEnd));
158 + }
147 159 track_session(&session, &state.db, user_id, &headers).await?;
148 160 tracing::info!(user_id = %user_id, event = "login_2fa_success", "User completed 2FA login");
149 161
@@ -27,7 +27,7 @@
27 27 // Step 1: Get presigned URL
28 28 var presignRes = await fetch('/api/users/me/insertions/presign', {
29 29 method: 'POST',
30 - headers: { 'Content-Type': 'application/json' },
30 + headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
31 31 body: JSON.stringify({
32 32 file_name: file.name,
33 33 content_type: file.type || 'audio/mpeg'
@@ -56,7 +56,7 @@
56 56 var title = file.name.replace(/\.[^.]+$/, '');
57 57 var confirmRes = await fetch('/api/users/me/insertions/confirm', {
58 58 method: 'POST',
59 - headers: { 'Content-Type': 'application/json' },
59 + headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
60 60 body: JSON.stringify({
61 61 s3_key: presignData.s3_key,
62 62 title: title,
@@ -106,7 +106,7 @@
106 106
107 107 fetch('/api/insertions/' + id, {
108 108 method: 'PUT',
109 - headers: { 'Content-Type': 'application/json' },
109 + headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
110 110 body: JSON.stringify({ title: newTitle })
111 111 }).then(function() {
112 112 htmx.ajax('GET', '/api/users/me/insertions', { target: '#insertion-library', swap: 'outerHTML' });
@@ -137,7 +137,7 @@
137 137
138 138 fetch('/api/items/' + itemId + '/insertions', {
139 139 method: 'POST',
140 - headers: { 'Content-Type': 'application/json' },
140 + headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
141 141 body: JSON.stringify({
142 142 insertion_id: insertionId,
143 143 position: position,
@@ -163,7 +163,7 @@
163 163 {% when crate::types::ItemContent::Other %}
164 164 {% endmatch %}
165 165
166 - {% if item.item_type == "Bundle" %}
166 + {% if item.item_type == "bundle" %}
167 167 <!-- Bundle Contents -->
168 168 <div class="content-section" id="bundle-section">
169 169 <div class="section-header">
@@ -28,7 +28,9 @@
28 28 </div>
29 29 <h1>{{ project.title }}<span class="dot">.</span></h1>
30 30 <div class="project-meta">
31 - {{ project.project_type }} &middot; makenot.work/p/{{ creator_username }}/{{ project.slug }}
31 + <a href="/p/{{ project.slug }}" style="opacity: 0.7;">makenot.work/p/{{ project.slug }}</a>
32 + &nbsp;&middot;&nbsp;
33 + <a href="/p/{{ project.slug }}" target="_blank" style="opacity: 0.7;">View live &rarr;</a>
32 34 </div>
33 35 </header>
34 36
@@ -42,11 +42,8 @@
42 42 </script>
43 43 <style>
44 44 .item-layout { display: grid; grid-template-columns: 400px 1fr; gap: 2rem; margin-bottom: 2rem; }
45 + .item-layout.no-media { grid-template-columns: 1fr; }
45 46 .item-media { background: var(--light-background); padding: 2rem; }
46 - .item-thumbnail { width: 100%; aspect-ratio: 1; background: var(--surface-muted); display: flex; align-items: center; justify-content: center; font-size: 5rem; opacity: 0.3; margin-bottom: 1.5rem; }
47 - .item-gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; }
48 - .gallery-thumb { aspect-ratio: 1; background: var(--surface-muted); cursor: pointer; transition: background 0.2s ease; }
49 - .gallery-thumb:hover { background: var(--border); }
50 47 .item-details { background: var(--light-background); padding: 2rem; }
51 48 .item-title { font-size: 2rem; margin-bottom: 0.5rem; }
52 49 .item-creator { font-size: 1rem; opacity: 0.7; margin-bottom: 1.5rem; }
@@ -122,15 +119,14 @@
122 119 <span>{{ item.title }}</span>
123 120 </nav>
124 121
125 - <div class="item-layout">
122 + <div class="item-layout{% if item.cover_image_url.is_none() %} no-media{% endif %}">
123 + {% if item.cover_image_url.is_some() %}
126 124 <div class="item-media">
127 - <div class="item-thumbnail">{{ item.thumbnail }}</div>
128 - <div class="item-gallery">
129 - <div class="gallery-thumb"></div>
130 - <div class="gallery-thumb"></div>
131 - <div class="gallery-thumb"></div>
132 - </div>
125 + {% if let Some(img) = item.cover_image_url %}
126 + <img src="{{ img }}" alt="{{ item.title }}" style="width: 100%; display: block;">
127 + {% endif %}
133 128 </div>
129 + {% endif %}
134 130
135 131 <div class="item-details">
136 132 <h1 class="item-title">{{ item.title }}</h1>
@@ -183,7 +179,18 @@
183 179 </ul>
184 180 </div>
185 181
186 - <button class="primary">Buy Once - {{ item.price }}</button>
182 + {% if item.is_free %}
183 + <button class="primary"
184 + hx-post="/api/library/add/{{ item.id }}"
185 + hx-swap="outerHTML">Add to Library - Free</button>
186 + {% else if item.pwyw_enabled %}
187 + <a href="/purchase/{{ item.id }}"><button class="primary">Pay What You Want - {{ item.price }}</button></a>
188 +
189 + <div class="payment-note">
190 + Secure payment via Stripe
191 + </div>
192 + {% else %}
193 + <a href="/purchase/{{ item.id }}"><button class="primary">Buy Once - {{ item.price }}</button></a>
187 194
188 195 <div class="payment-note">
189 196 Secure payment via Stripe
@@ -200,6 +207,7 @@
200 207 </form>
201 208 </details>
202 209 {% endif %}
210 + {% endif %}
203 211 </div>
204 212 </div>
205 213
@@ -126,7 +126,7 @@
126 126 <a href="/p/{{ project.slug }}/blog" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Blog</a>
127 127 {% endif %}
128 128 {% for repo in &git_repos %}
129 - <a href="{{ repo.1 }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">{{ repo.0 }}</a>
129 + <a href="{{ repo.1 }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Git ({{ repo.0 }})</a>
130 130 {% endfor %}
131 131 {% if let Some(url) = community_url %}
132 132 <a href="{{ url }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Community</a>
@@ -166,6 +166,8 @@
166 166 <button class="primary"
167 167 hx-post="/api/library/add/{{ item.id }}"
168 168 hx-swap="outerHTML">Add to Library</button>
169 + {% else if item.pwyw_enabled %}
170 + <a href="/purchase/{{ item.id }}"><button class="primary">Pay What You Want</button></a>
169 171 {% else %}
170 172 <a href="/purchase/{{ item.id }}"><button class="primary">Buy Once</button></a>
171 173 {% endif %}
@@ -3,10 +3,7 @@
3 3 <div class="form-section">
4 4 <h2>Project Information</h2>
5 5
6 - <form hx-put="/api/projects/{{ project.id }}"
7 - hx-target="#project-save-status"
8 - hx-swap="innerHTML"
9 - hx-indicator="#project-spinner">
6 + <form id="project-info-form" onsubmit="return saveProjectInfo(event, '{{ project.id }}')">
10 7 <div class="form-group">
11 8 <label for="project-name">Project Name</label>
12 9 <input type="text" id="project-name" name="title" value="{{ project.title }}" placeholder="Project name" title="The public name of your project" required>
@@ -107,7 +104,7 @@
107 104 body: JSON.stringify({name: name})
108 105 }).then(function() {
109 106 // Reload the settings tab to reflect changes
110 - htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tab/settings', {target: '#tab-content', swap: 'innerHTML'});
107 + htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tabs/settings', {target: '#tab-content', swap: 'innerHTML'});
111 108 }).catch(function() {
112 109 var status = document.getElementById('create-repo-status');
113 110 if (status) {
@@ -147,7 +144,7 @@
147 144 body: JSON.stringify({name: name})
148 145 }).then(function(r) {
149 146 if (r.ok) {
150 - htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tab/settings', {target: '#tab-content', swap: 'innerHTML'});
147 + htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tabs/settings', {target: '#tab-content', swap: 'innerHTML'});
151 148 } else {
152 149 return r.json().then(function(data) {
153 150 status.textContent = data.error || 'Failed to create repository';
@@ -239,6 +236,36 @@
239 236 });
240 237 })();
241 238
239 + // Project info save
240 + function saveProjectInfo(e, projectId) {
241 + e.preventDefault();
242 + var status = document.getElementById('project-save-status');
243 + var data = {
244 + title: document.getElementById('project-name').value,
245 + description: document.getElementById('project-description').value,
246 + category: document.getElementById('settings-category').value
247 + };
248 + fetch('/api/projects/' + projectId, {
249 + method: 'PUT',
250 + headers: {'Content-Type': 'application/json', ...csrfHeaders()},
251 + body: JSON.stringify(data)
252 + }).then(function(r) {
253 + if (r.ok) {
254 + if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
255 + setTimeout(function() { if (status) status.textContent = ''; }, 2000);
256 + } else {
257 + r.text().then(function(body) {
258 + var msg = 'Save failed';
259 + try { msg = JSON.parse(body).error || msg; } catch(_) {}
260 + if (status) { status.textContent = msg; status.style.color = 'var(--error-color)'; }
261 + });
262 + }
263 + }).catch(function() {
264 + if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
265 + });
266 + return false;
267 + }
268 +
242 269 // Features update
243 270 function updateFeatures(projectId) {
244 271 var checkboxes = document.querySelectorAll('#features-grid input[type="checkbox"]');
@@ -46,16 +46,14 @@
46 46 <form hx-post="/api/collections"
47 47 hx-target="#tab-content"
48 48 hx-swap="innerHTML"
49 - hx-headers='{"Content-Type": "application/json"}'
50 - hx-vals='js:{slug: document.getElementById("new-coll-slug").value, title: document.getElementById("new-coll-title").value, description: document.getElementById("new-coll-desc").value || undefined, is_public: document.getElementById("new-coll-public").checked}'
51 49 style="margin-top: 1rem;">
52 50 <div class="form-group">
53 51 <label for="new-coll-title">Title</label>
54 - <input type="text" id="new-coll-title" maxlength="100" required placeholder="My Reading List">
52 + <input type="text" id="new-coll-title" name="title" maxlength="100" required placeholder="My Reading List">
55 53 </div>
56 54 <div class="form-group">
57 55 <label for="new-coll-slug">Slug</label>
58 - <input type="text" id="new-coll-slug" maxlength="100" required placeholder="my-reading-list" pattern="[a-zA-Z0-9\-]+"
56 + <input type="text" id="new-coll-slug" name="slug" maxlength="100" required placeholder="my-reading-list" pattern="[a-zA-Z0-9\-]+"
59 57 hx-post="/api/validate/collection-slug"
60 58 hx-trigger="keyup changed delay:500ms"
61 59 hx-target="#coll-slug-status"
@@ -66,10 +64,10 @@
66 64 </div>
67 65 <div class="form-group">
68 66 <label for="new-coll-desc">Description (optional)</label>
69 - <textarea id="new-coll-desc" maxlength="500" rows="2"></textarea>
67 + <textarea id="new-coll-desc" name="description" maxlength="500" rows="2"></textarea>
70 68 </div>
71 69 <label class="checkbox-group">
72 - <input type="checkbox" id="new-coll-public"> Public (visible on your profile)
70 + <input type="checkbox" id="new-coll-public" name="is_public" value="true"> Public (visible on your profile)
73 71 </label>
74 72 <button type="submit">Create Collection</button>
75 73 </form>
@@ -242,18 +242,19 @@
242 242 hx-target="#password-status"
243 243 hx-swap="innerHTML"
244 244 hx-indicator="#password-spinner"
245 - hx-on::after-request="if(event.detail.successful) this.reset()">
245 + hx-on::after-request="if(event.detail.successful) this.reset()"
246 + 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;}">
246 247 <div class="form-group">
247 248 <label for="current-password">Current Password</label>
248 - <input type="password" id="current-password" name="current_password">
249 + <input type="password" id="current-password" name="current_password" required>
249 250 </div>
250 251 <div class="form-group">
251 252 <label for="new-password">New Password</label>
252 - <input type="password" id="new-password" name="new_password" minlength="8">
253 + <input type="password" id="new-password" name="new_password" minlength="8" required>
253 254 </div>
254 255 <div class="form-group">
255 256 <label for="confirm-password">Confirm New Password</label>
256 - <input type="password" id="confirm-password" name="confirm_password">
257 + <input type="password" id="confirm-password" name="confirm_password" required>
257 258 </div>
258 259 <button class="primary" type="submit">
259 260 Update Password