feat(forms): public client request-form page #485

Merged
longjacksonle merged 3 commits from feat/PMS-730-public-request-form-page into main 2026-08-06 21:26:32 +02:00

First slice of the frontend for the client-request loop (mokosh-server #495, #497, #498, #499): the page a client actually lands on.

Why this surface first

The loop has four frontend surfaces: the admin form builder, the send-a-link action, this public page, and the measured-duration readout on the KB article. Three of them have a fallback, since an MSP user can drive the API directly in a pinch. This one does not: a client cannot curl. Until it exists, nothing an agent does with the other three reaches anybody.

It also had an exact precedent to follow in portal_set_password.rs, which is the same shape (emailed token, no session, status-contract-driven copy).

What it does

/request-forms/:token fetches GET /api/v1/public/request-forms/{token}, renders the form, and posts the answers back to the same path. No bearer is sent: the token in the path is the only credential the visitor has.

The page renders whatever field set the server hands it. Types, labels, option sets, length bounds and cross-field rules all come from the definition (PMS-731), so adding a request type server-side needs no change here.

Details worth reviewing

Three things a naive client would get wrong, each covered by a unit test. A checkbox sends a real JSON bool rather than the string "false", because the server type-checks each field and would reject the string. A blank optional answer is omitted rather than sent as "", which is what the server does with it anyway after trimming. And a field the server will require through a required_if rule is marked required as soon as its condition holds, so the asterisk on forward_to appears when mailbox_handling is set to forward, not on submit.

Validation follows docs/form-conventions.md. Submit evaluates every required field and sets each failed slot before bailing once, so one missing answer never masks another, and whitespace is not accepted as an answer.

Each server status gets its own copy. 410 says the request is already with us; 400 says ask for a new link. The server deliberately does not distinguish expired from unknown from malformed (it would otherwise be an oracle for which token ids exist), so neither does this page. A 422 is routed to the offending inputs, with any message about a field this form does not render falling back to the form-level banner instead of vanishing.

Forward compatibility. An unknown rule kind from a newer server deserialises to a no-op rather than failing the render, and an unknown field type degrades to a text input, leaving the server as the authority on validation either way.

A new fetch helper. get_typed mirrors the existing post_typed: get_authed_typed would attach whatever bearer is in memory, and the plain get collapses failures to a String, which would make 410 and 400 indistinguishable.

The gate that mattered most

lib.rs carries the MAPPS-396 recurrence test asserting that every link mokosh-server emails resolves to a real route rather than the catch-all NotFound. It exists because the portal setup link sat in customers' inboxes with no page behind it. The request link is exactly that case, so the entry in EMAILED_LINKS matters more than the route declaration does.

Verification

cargo check --target wasm32-unknown-unknown, cargo fmt --all --check, cargo test (255 passed, 0 failed, including 3 new unit tests and the emailed-link gate), and both guard scripts (check-theme-tokens, check-runner-labels).

just check-clippy reports two pre-existing failures in src/pages/projects.rs and src/pages/time.rs (manual_div_ceil and unnecessary_sort_by). Both reproduce on unmodified main with a local clippy newer than CI's, so they are not from this branch and are left alone; nothing in the new code trips a lint.

Next slices, not in this PR

The admin form builder, the send-a-link action, and the measured-duration readout on the KB article page.

First slice of the frontend for the client-request loop (mokosh-server #495, #497, #498, #499): the page a client actually lands on. ## Why this surface first The loop has four frontend surfaces: the admin form builder, the send-a-link action, this public page, and the measured-duration readout on the KB article. Three of them have a fallback, since an MSP user can drive the API directly in a pinch. This one does not: a client cannot curl. Until it exists, nothing an agent does with the other three reaches anybody. It also had an exact precedent to follow in `portal_set_password.rs`, which is the same shape (emailed token, no session, status-contract-driven copy). ## What it does `/request-forms/:token` fetches `GET /api/v1/public/request-forms/{token}`, renders the form, and posts the answers back to the same path. No bearer is sent: the token in the path is the only credential the visitor has. The page renders whatever field set the server hands it. Types, labels, option sets, length bounds and cross-field rules all come from the definition (PMS-731), so adding a request type server-side needs no change here. ## Details worth reviewing **Three things a naive client would get wrong, each covered by a unit test.** A checkbox sends a real JSON bool rather than the string `"false"`, because the server type-checks each field and would reject the string. A blank optional answer is omitted rather than sent as `""`, which is what the server does with it anyway after trimming. And a field the server will require through a `required_if` rule is marked required as soon as its condition holds, so the asterisk on `forward_to` appears when `mailbox_handling` is set to `forward`, not on submit. **Validation follows `docs/form-conventions.md`.** Submit evaluates every required field and sets each failed slot before bailing once, so one missing answer never masks another, and whitespace is not accepted as an answer. **Each server status gets its own copy.** 410 says the request is already with us; 400 says ask for a new link. The server deliberately does not distinguish expired from unknown from malformed (it would otherwise be an oracle for which token ids exist), so neither does this page. A 422 is routed to the offending inputs, with any message about a field this form does not render falling back to the form-level banner instead of vanishing. **Forward compatibility.** An unknown rule kind from a newer server deserialises to a no-op rather than failing the render, and an unknown field type degrades to a text input, leaving the server as the authority on validation either way. **A new fetch helper.** `get_typed` mirrors the existing `post_typed`: `get_authed_typed` would attach whatever bearer is in memory, and the plain `get` collapses failures to a `String`, which would make 410 and 400 indistinguishable. ## The gate that mattered most `lib.rs` carries the MAPPS-396 recurrence test asserting that every link mokosh-server emails resolves to a real route rather than the catch-all NotFound. It exists because the portal setup link sat in customers' inboxes with no page behind it. The request link is exactly that case, so the entry in `EMAILED_LINKS` matters more than the route declaration does. ## Verification `cargo check --target wasm32-unknown-unknown`, `cargo fmt --all --check`, `cargo test` (255 passed, 0 failed, including 3 new unit tests and the emailed-link gate), and both guard scripts (`check-theme-tokens`, `check-runner-labels`). `just check-clippy` reports two pre-existing failures in `src/pages/projects.rs` and `src/pages/time.rs` (`manual_div_ceil` and `unnecessary_sort_by`). Both reproduce on unmodified `main` with a local clippy newer than CI's, so they are not from this branch and are left alone; nothing in the new code trips a lint. ## Next slices, not in this PR The admin form builder, the send-a-link action, and the measured-duration readout on the KB article page.
PMS-730's client request form is fetched by a visitor with no session of any kind: the emailed token in the path is the only credential. `get_authed_typed` would attach whatever bearer happens to be in memory, and the plain `get` collapses every failure to a `String`, so the caller could not tell 410 (the link was already submitted) from 400 (expired or unknown). This mirrors `post_typed`, which exists for the same reason on the sign-in form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX
The client-facing half of PMS-730. A client opens the link mokosh-server emailed them, fills in the form, and gets back a ticket number to quote. No bearer is sent, because they have no session.

The page renders whatever field set the server hands it rather than hard-coding any MACD knowledge: types, labels, option sets, length bounds and the cross-field rules all come from the definition (PMS-731), so adding a request type server-side needs no frontend change.

Following docs/form-conventions.md, submit evaluates EVERY required field and sets each failed slot before bailing once, so one missing answer never masks another, and whitespace is not treated as an answer, matching the server's own trim.

Three things the client would otherwise get wrong. A checkbox sends a real JSON bool rather than the string "false", since the server type-checks each field. A blank optional answer is omitted rather than sent as "", which is what the server does with it anyway. And a field the server will require through a `required_if` rule is marked required as soon as its condition holds, so the asterisk appears when the answer it depends on is given rather than only on submit.

Each server status gets its own copy, so a client can tell "your request is already with us" (410) from "ask for a new link" (400). A 422 is routed to the offending inputs; any message about a field this form does not render falls back to the form-level banner instead of vanishing. An unknown rule kind from a newer server deserialises to a no-op rather than failing the render, leaving the server as the authority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX
feat(routes): route /request-forms/:token and gate the emailed link
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 2m32s
Create release / Create release from merged PR (pull_request) Has been skipped
47aa333b47
Mounts the request-form page alongside the other public entry points, outside `AuthGuard`, since the visitor is a client with no session.

Also adds the link to the MAPPS-396 recurrence gate, which asserts that every link mokosh-server emails resolves to a real route rather than the catch-all NotFound. That test exists because the portal setup link sat in customers' inboxes with no page behind it; this is exactly the case it was built to catch, so the entry matters more than the route does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-06 21:25:25 +02:00
longjacksonle deleted branch feat/PMS-730-public-request-form-page 2026-08-06 21:26:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-apps!485
No description provided.