max / makenotwork
4 files changed,
+77 insertions,
-4 deletions
| @@ -272,6 +272,10 @@ pub(super) async fn update_project( | |||
| 272 | 272 | // `(dollars * 100.0).round() as i32` form silently turns NaN into 0 | |
| 273 | 273 | // and saturates large values to i32::MAX. | |
| 274 | 274 | let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?; | |
| 275 | + | // Enforce the $10k cap (and non-negative) the same way the form | |
| 276 | + | // wizard does — without PriceCents::new the JSON API bypasses the cap | |
| 277 | + | // and a creator can set a price up to ~$21.4M (ultra-fuzz Run 10 UX S1). | |
| 278 | + | let cents = db::PriceCents::new(cents)?.as_i32(); | |
| 275 | 279 | if cents < 50 { | |
| 276 | 280 | return Err(AppError::validation( | |
| 277 | 281 | "price_dollars must be at least 0.50", | |
| @@ -284,7 +288,8 @@ pub(super) async fn update_project( | |||
| 284 | 288 | ||
| 285 | 289 | let pwyw_min_cents = if kind == db::PricingKind::Pwyw { | |
| 286 | 290 | let dollars = req.pwyw_min_dollars.unwrap_or(0.0); | |
| 287 | - | Some(crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?) | |
| 291 | + | let cents = crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?; | |
| 292 | + | Some(db::PriceCents::new(cents)?.as_i32()) | |
| 288 | 293 | } else { | |
| 289 | 294 | None | |
| 290 | 295 | }; |
| @@ -166,8 +166,25 @@ pub async fn step_type_create( | |||
| 166 | 166 | session: Session, | |
| 167 | 167 | AuthUser(user): AuthUser, | |
| 168 | 168 | Path(slug): Path<String>, | |
| 169 | + | headers: HeaderMap, | |
| 169 | 170 | Form(form): Form<TypeForm>, | |
| 170 | 171 | ) -> Result<Response> { | |
| 172 | + | // Surface validation errors as an inline toast (HX-Reswap: none) instead of | |
| 173 | + | // swapping the full-page 422 into #wizard-step and wiping the selection | |
| 174 | + | // (ultra-fuzz Run 10 UX S2). | |
| 175 | + | match step_type_create_inner(state, session, user, slug, form).await { | |
| 176 | + | Ok(resp) => Ok(resp), | |
| 177 | + | Err(e) => super::wizard_validation_toast(&headers, e), | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | async fn step_type_create_inner( | |
| 182 | + | state: AppState, | |
| 183 | + | session: Session, | |
| 184 | + | user: crate::auth::SessionUser, | |
| 185 | + | slug: String, | |
| 186 | + | form: TypeForm, | |
| 187 | + | ) -> Result<Response> { | |
| 171 | 188 | user.check_not_suspended()?; | |
| 172 | 189 | ||
| 173 | 190 | let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?; |
| @@ -8,12 +8,34 @@ | |||
| 8 | 8 | pub mod item; | |
| 9 | 9 | pub mod project; | |
| 10 | 10 | ||
| 11 | + | use axum::http::{HeaderMap, HeaderValue, StatusCode}; | |
| 12 | + | use axum::response::{IntoResponse, Response}; | |
| 11 | 13 | use axum::routing::get; | |
| 12 | 14 | use crate::{ | |
| 13 | 15 | csrf::{post_csrf, with_csrf, CsrfRouter}, | |
| 16 | + | error::{AppError, Result}, | |
| 14 | 17 | AppState, | |
| 15 | 18 | }; | |
| 16 | 19 | ||
| 20 | + | /// Convert a validation error on an HTMX wizard-step submit into an inline toast | |
| 21 | + | /// with `HX-Reswap: none`, so a failed submit doesn't swap the full-page 422 | |
| 22 | + | /// template into `#wizard-step` and wipe the user's typed input. Non-validation | |
| 23 | + | /// errors and non-HTMX requests propagate unchanged. Shared by every step | |
| 24 | + | /// handler — the create handlers regressed by omitting it (ultra-fuzz Run 10 UX S2). | |
| 25 | + | pub(crate) fn wizard_validation_toast(headers: &HeaderMap, e: AppError) -> Result<Response> { | |
| 26 | + | if crate::helpers::is_htmx_request(headers) | |
| 27 | + | && let AppError::Validation(ref v) = e | |
| 28 | + | { | |
| 29 | + | let mut resp = StatusCode::OK.into_response(); | |
| 30 | + | resp.headers_mut() | |
| 31 | + | .insert("HX-Trigger", crate::helpers::hx_toast(&v.to_string(), "error")); | |
| 32 | + | resp.headers_mut() | |
| 33 | + | .insert("HX-Reswap", HeaderValue::from_static("none")); | |
| 34 | + | return Ok(resp); | |
| 35 | + | } | |
| 36 | + | Err(e) | |
| 37 | + | } | |
| 38 | + | ||
| 17 | 39 | /// Step navigation helpers shared by both wizards. | |
| 18 | 40 | pub fn next_step<'a>(steps: &'a [&str], current: &str) -> Option<&'a str> { | |
| 19 | 41 | steps |
| @@ -105,8 +105,25 @@ pub async fn step_basics_create( | |||
| 105 | 105 | State(state): State<AppState>, | |
| 106 | 106 | session: Session, | |
| 107 | 107 | AuthUser(user): AuthUser, | |
| 108 | + | headers: HeaderMap, | |
| 108 | 109 | ValidatedHtmlForm(form): ValidatedHtmlForm<BasicsForm>, | |
| 109 | 110 | ) -> Result<Response> { | |
| 111 | + | // Surface validation errors as an inline toast (HX-Reswap: none) instead of | |
| 112 | + | // swapping the full-page 422 into #wizard-step and wiping typed input — the | |
| 113 | + | // guard the step_save handlers carry but this create handler had dropped | |
| 114 | + | // (ultra-fuzz Run 10 UX S2). | |
| 115 | + | match step_basics_create_inner(state, session, user, form).await { | |
| 116 | + | Ok(resp) => Ok(resp), | |
| 117 | + | Err(e) => super::wizard_validation_toast(&headers, e), | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | async fn step_basics_create_inner( | |
| 122 | + | state: AppState, | |
| 123 | + | session: Session, | |
| 124 | + | user: crate::auth::SessionUser, | |
| 125 | + | form: BasicsForm, | |
| 126 | + | ) -> Result<Response> { | |
| 110 | 127 | user.check_not_suspended()?; | |
| 111 | 128 | if !user.can_create_projects { | |
| 112 | 129 | return Err(AppError::Forbidden); | |
| @@ -151,7 +168,14 @@ pub async fn step_basics_create( | |||
| 151 | 168 | } | |
| 152 | 169 | ||
| 153 | 170 | // Save AI tier | |
| 154 | - | let ai_tier = form.ai_tier.as_deref().unwrap_or("handmade").parse::<db::AiTier>().unwrap_or(db::AiTier::Handmade); | |
| 171 | + | // A present-but-invalid ai_tier is a client error, not silently "handmade"; | |
| 172 | + | // absent/empty defaults to handmade (ultra-fuzz Run 10 UX M3). | |
| 173 | + | let ai_tier = match form.ai_tier.as_deref() { | |
| 174 | + | None | Some("") => db::AiTier::Handmade, | |
| 175 | + | Some(s) => s | |
| 176 | + | .parse::<db::AiTier>() | |
| 177 | + | .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?, | |
| 178 | + | }; | |
| 155 | 179 | let ai_disclosure = if ai_tier == db::AiTier::Assisted { form.ai_disclosure.as_deref() } else { None }; | |
| 156 | 180 | db::projects::update_project_ai_tier(&state.db, project.id, user.id, ai_tier, ai_disclosure).await?; | |
| 157 | 181 | ||
| @@ -239,8 +263,13 @@ async fn save_basics( | |||
| 239 | 263 | project: &db::DbProject, | |
| 240 | 264 | form: &HashMap<String, String>, | |
| 241 | 265 | ) -> Result<()> { | |
| 242 | - | let ai_tier = form.get("ai_tier").map(|s| s.as_str()).unwrap_or("handmade") | |
| 243 | - | .parse::<db::AiTier>().unwrap_or(db::AiTier::Handmade); | |
| 266 | + | // Present-but-invalid ai_tier is a client error, not silently "handmade". | |
| 267 | + | let ai_tier = match form.get("ai_tier").map(|s| s.as_str()) { | |
| 268 | + | None | Some("") => db::AiTier::Handmade, | |
| 269 | + | Some(s) => s | |
| 270 | + | .parse::<db::AiTier>() | |
| 271 | + | .map_err(|_| AppError::validation("Invalid AI tier".to_string()))?, | |
| 272 | + | }; | |
| 244 | 273 | let ai_disclosure = if ai_tier == db::AiTier::Assisted { | |
| 245 | 274 | form.get("ai_disclosure").map(|s| s.as_str()) | |
| 246 | 275 | } else { |