From adaca8976001d37c12f4d8de57917e16a98aef4a Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 31 Aug 2026 19:34:19 +0900 Subject: [PATCH] Add public forms plugin Provide validated public form definitions and submissions with an administrator builder and inbox. Snapshot each form definition with its submission and keep submitted values out of audit history and generic event payloads. --- CHANGELOG.md | 1 + Cargo.lock | 18 + README.md | 3 +- apps/example-app/Cargo.toml | 1 + apps/example-app/src/main.rs | 2 +- apps/example-app/tests/openapi.rs | 7 + apps/example-app/tests/system.rs | 141 +++ bun.lock | 15 + crates/cli/Cargo.toml | 4 + docs/forms.md | 72 ++ plugins/forms/Cargo.toml | 22 + plugins/forms/app/(forms)/page.tsx | 1111 +++++++++++++++++ plugins/forms/app/(forms)/route.meta.json | 6 + .../0001_create_forms.vespertide.json | 41 + plugins/forms/models/definition.json | 20 + plugins/forms/models/submission.json | 13 + plugins/forms/package.json | 19 + plugins/forms/src/lib.rs | 13 + plugins/forms/src/models/definition.rs | 27 + plugins/forms/src/models/mod.rs | 2 + plugins/forms/src/models/submission.rs | 20 + plugins/forms/src/routes/mod.rs | 927 ++++++++++++++ plugins/forms/tsconfig.json | 11 + plugins/forms/vespertide.json | 16 + 24 files changed, 2510 insertions(+), 2 deletions(-) create mode 100644 docs/forms.md create mode 100644 plugins/forms/Cargo.toml create mode 100644 plugins/forms/app/(forms)/page.tsx create mode 100644 plugins/forms/app/(forms)/route.meta.json create mode 100644 plugins/forms/migrations/0001_create_forms.vespertide.json create mode 100644 plugins/forms/models/definition.json create mode 100644 plugins/forms/models/submission.json create mode 100644 plugins/forms/package.json create mode 100644 plugins/forms/src/lib.rs create mode 100644 plugins/forms/src/models/definition.rs create mode 100644 plugins/forms/src/models/mod.rs create mode 100644 plugins/forms/src/models/submission.rs create mode 100644 plugins/forms/src/routes/mod.rs create mode 100644 plugins/forms/tsconfig.json create mode 100644 plugins/forms/vespertide.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f6880..2d99495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The project is pre-1.0, so breaking changes can appear in any release. ### Added +- The `forms` plugin adds an administrator form builder and submission inbox with a deliberately small typed field vocabulary. Enabled forms expose exact public definition and submission endpoints; the server revalidates every value, applies a per-form hourly cap, snapshots definitions with submissions, and emits a value-free `forms.submitted` outbox event so personal data cannot enter audit history or generic webhooks. - The `search` plugin adds an administrator search page and paginated `/api/search` endpoint backed by SQLite FTS5. Content titles receive higher relevance weight than recursively flattened scalar fields, with exact collection and publication-status filters. - Search startup backfills existing content and rebuilds its external-content FTS5 index. An Inline subscriber applies create, update, publish, unpublish, and delete projections in the content transaction, so index failures roll back the source write rather than creating drift. - The `webhooks` plugin adds administrator endpoint CRUD, write-only signing secrets, exact event-name filters, per-endpoint delivery history, five-attempt dead letters, and explicit manual retries. Successful endpoints are not resent when another endpoint fails. diff --git a/Cargo.lock b/Cargo.lock index 722ce84..2851074 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,6 +1349,7 @@ dependencies = [ "content", "example-memo-plugin", "example-plugin", + "forms", "hmac 0.13.0", "media", "reqwest", @@ -1451,6 +1452,23 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forms" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "rand 0.10.2", + "sea-orm", + "serde", + "serde_json", + "tracing", + "vespera", + "vespertide", + "yeollin-plugin", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/README.md b/README.md index 6a64df9..d7ed8b9 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Yeollin CMS is a Tauri-inspired, plugin-based CMS *framework* rather than a fini |------|----------| | `crates/` | The Rust workspace crates: `core` (shared types), `auth` (JWT, Argon2, middleware), `plugin` (`PluginMetadata`, `FrontendAssets`), `plugin-macros` (`yeollin_plugin!`, `yeollin_app!`), `app` (`YeollinAppBuilder` runtime), `cli` (`init`, `prebuild`, `dev`, `build`). | | `packages/` | The Node workspace. `packages/app` is the vinext frontend template that gets extracted into `.yeollin/app/` at prebuild time. It is a template, not the running app. | -| `plugins/` | Plugin crates. `auth` owns accounts and sessions; `audit-log` reads explicitly marked outbox events; `media` owns runtime image uploads; `content` demonstrates compile-time typed draft/publish collections; `search` indexes content with SQLite FTS5; `webhooks` delivers signed events with retry and dead-letter history; `example-plugin` is a minimal library plugin; `example-memo-plugin` demonstrates database CRUD, typed settings, and audited events. | +| `plugins/` | Plugin crates. `auth` owns accounts and sessions; `audit-log` reads explicitly marked outbox events; `forms` owns validated public forms and a private submission inbox; `media` owns runtime image uploads; `content` demonstrates compile-time typed draft/publish collections; `search` indexes content with SQLite FTS5; `webhooks` delivers signed events with retry and dead-letter history; `example-plugin` is a minimal library plugin; `example-memo-plugin` demonstrates database CRUD, typed settings, and audited events. | | `apps/` | Standalone application crates. `apps/example-app` wires the example plugins together with `yeollin_app!` and is the entry point used for local development. | `.yeollin/` is generated during prebuild and is gitignored. Never edit it by hand. @@ -100,6 +100,7 @@ CI additionally builds the release binary with - [Architecture overview](docs/architecture.md) - [Plugin authoring](docs/plugin-authoring.md) +- [Forms plugin](docs/forms.md) - [Contributing guide](CONTRIBUTING.md) - [Security policy](SECURITY.md) - [Changelog](CHANGELOG.md) diff --git a/apps/example-app/Cargo.toml b/apps/example-app/Cargo.toml index 2d94eca..722a662 100644 --- a/apps/example-app/Cargo.toml +++ b/apps/example-app/Cargo.toml @@ -28,6 +28,7 @@ media = { path = "../../plugins/media" } content = { path = "../../plugins/content" } webhooks = { path = "../../plugins/webhooks" } search = { path = "../../plugins/search" } +forms = { path = "../../plugins/forms" } [dev-dependencies] reqwest = { workspace = true, features = ["json", "multipart"] } diff --git a/apps/example-app/src/main.rs b/apps/example-app/src/main.rs index b5e9dea..4fd6218 100644 --- a/apps/example-app/src/main.rs +++ b/apps/example-app/src/main.rs @@ -42,7 +42,7 @@ async fn main() -> anyhow::Result<()> { // Create app builder using yeollin_app! macro // This macro handles both register_plugin() and vespera merge in one call let app = yeollin::yeollin_app! { - plugins: [audit_log, auth, content, example_memo_plugin, example_plugin, media, search, webhooks], + plugins: [audit_log, auth, content, example_memo_plugin, example_plugin, forms, media, search, webhooks], openapi: "openapi.json", title: "Example CMS API", version: "1.0.0", diff --git a/apps/example-app/tests/openapi.rs b/apps/example-app/tests/openapi.rs index 0872ea8..f714ee7 100644 --- a/apps/example-app/tests/openapi.rs +++ b/apps/example-app/tests/openapi.rs @@ -55,6 +55,8 @@ fn documented_responses_reference_named_schemas() { ("/api/example-memo-plugin/{id}", "get"), ("/api/auth/login", "post"), ("/api/auth/me", "get"), + ("/api/forms", "get"), + ("/api/forms/public", "get"), ] { let schema = &spec["paths"][path][method]["responses"]["200"]["content"] ["application/json"]["schema"]; @@ -122,6 +124,11 @@ fn every_plugin_route_lives_under_its_declared_namespace() { "/api/example-memo-plugin/{id}", "/api/example-plugin/items/", "/api/example-plugin/items/{id}", + "/api/forms", + "/api/forms/public", + "/api/forms/submit", + "/api/forms/{id}", + "/api/forms/{id}/submissions", "/api/media", "/api/media/file", "/api/media/{id}", diff --git a/apps/example-app/tests/system.rs b/apps/example-app/tests/system.rs index e9970bc..7d70ab8 100644 --- a/apps/example-app/tests/system.rs +++ b/apps/example-app/tests/system.rs @@ -1689,3 +1689,144 @@ async fn assembled_system_throttles_repeated_failures() { let correct = login(&client, &server, PASSWORD).await; assert_eq!(correct.status(), 429); } + +#[tokio::test] +async fn assembled_system_validates_private_form_submissions() { + let server = start().await; + let client = reqwest::Client::new(); + + let anonymous = client.get(server.url("/api/forms")).send().await.unwrap(); + assert_eq!(anonymous.status(), 401, "form management is protected"); + + let token = admin_token(&client, &server).await; + let created = client + .post(server.url("/api/forms")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "name": " Contact us ", + "description": "Ask a question", + "fields": [ + { + "id": "email", + "label": "Email address", + "kind": "email", + "required": true, + "options": [], + "placeholder": "you@example.com", + }, + { + "id": "terms", + "label": "Accept terms", + "kind": "checkbox", + "required": true, + "options": [], + "placeholder": null, + } + ], + "successMessage": "Thank you for getting in touch.", + "maxSubmissionsPerHour": 1, + })) + .send() + .await + .unwrap(); + assert_eq!(created.status(), 200); + let form: Value = created.json().await.unwrap(); + assert_eq!(form["name"], "Contact us"); + let form_id = form["id"].as_str().expect("form id"); + + let protected = client + .get(server.url(&format!("/api/forms/{form_id}"))) + .send() + .await + .unwrap(); + assert_eq!( + protected.status(), + 401, + "only the exact public route is open" + ); + + let public: Value = client + .get(server.url(&format!("/api/forms/public?id={form_id}"))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(public["id"], form_id); + assert!(public.get("createdBy").is_none()); + assert!(public.get("maxSubmissionsPerHour").is_none()); + + let unknown = client + .post(server.url("/api/forms/submit")) + .json(&serde_json::json!({ + "formId": form_id, + "values": { "email": "ada@example.com", "terms": true, "unknown": "no" }, + })) + .send() + .await + .unwrap(); + assert_eq!(unknown.status(), 400, "unknown fields must be rejected"); + + let submitted = client + .post(server.url("/api/forms/submit")) + .json(&serde_json::json!({ + "formId": form_id, + "values": { "email": " ada@example.com ", "terms": true }, + })) + .send() + .await + .unwrap(); + assert_eq!(submitted.status(), 200); + let submitted: Value = submitted.json().await.unwrap(); + assert_eq!( + submitted["successMessage"], + "Thank you for getting in touch." + ); + assert!(submitted["submissionId"].as_str().is_some()); + + let quota = client + .post(server.url("/api/forms/submit")) + .json(&serde_json::json!({ + "formId": form_id, + "values": { "email": "another@example.com", "terms": true }, + })) + .send() + .await + .unwrap(); + assert_eq!(quota.status(), 429, "the form's public quota is enforced"); + + let inbox: Value = client + .get(server.url(&format!("/api/forms/{form_id}/submissions"))) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(inbox["total"], 1); + assert_eq!( + inbox["submissions"][0]["values"]["email"], + "ada@example.com" + ); + assert_eq!(inbox["submissions"][0]["values"]["terms"], true); + + let db = Database::connect(server.database_url()).await.unwrap(); + let event = db + .query_one_raw(Statement::from_string( + DbBackend::Sqlite, + "SELECT audit, payload FROM events WHERE name = 'forms.submitted' ORDER BY id DESC LIMIT 1", + )) + .await + .unwrap() + .expect("submission must emit an outbox event"); + let audit: bool = event.try_get("", "audit").unwrap(); + let payload: Value = event.try_get("", "payload").unwrap(); + assert!(!audit, "form values must not become audit history"); + assert!( + payload.get("email").is_none(), + "event payload must omit values" + ); + assert_eq!(payload["formId"], form_id); +} diff --git a/bun.lock b/bun.lock index 615f630..456361c 100644 --- a/bun.lock +++ b/bun.lock @@ -106,6 +106,19 @@ "vinext": "^1.0.0-beta.8", }, }, + "plugins/forms": { + "name": "@yeollin-plugin/forms", + "version": "0.1.0", + "dependencies": { + "@devup-ui/react": "^1.0.41", + "react": "^19.2.8", + }, + "devDependencies": { + "@types/react": "^19", + "typescript": "^7.0", + "vinext": "^1.0.0-beta.8", + }, + }, "plugins/media": { "name": "@yeollin-plugin/media", "version": "0.1.0", @@ -551,6 +564,8 @@ "@yeollin-plugin/example-plugin": ["@yeollin-plugin/example-plugin@workspace:plugins/example-plugin"], + "@yeollin-plugin/forms": ["@yeollin-plugin/forms@workspace:plugins/forms"], + "@yeollin-plugin/media": ["@yeollin-plugin/media@workspace:plugins/media"], "@yeollin-plugin/search": ["@yeollin-plugin/search@workspace:plugins/search"], diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 65edc86..bc1f367 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -8,6 +8,10 @@ description = "CLI for Yeollin CMS - prebuild, dev, and build commands" [[bin]] name = "yeollin" path = "src/main.rs" +# The framework library already owns the `yeollin` rustdoc path. Keeping this +# command-line binary out of rustdoc avoids a non-deterministic output collision +# while preserving documentation checks for the public library and macros. +doc = false [dependencies] # CLI diff --git a/docs/forms.md b/docs/forms.md new file mode 100644 index 0000000..41ff1d3 --- /dev/null +++ b/docs/forms.md @@ -0,0 +1,72 @@ +# Forms plugin + +`forms` supplies one administrator screen at `/forms`, a public form-definition +endpoint, and a public submission endpoint. It is registered in +`apps/example-app`; another application registers it in the usual way: + +```bash +cd apps/my-app +yeollin plugin add forms +yeollin plugin doctor +``` + +## Administrator workflow + +Administrators create forms in the **Forms** screen. A form has a display name, +description, success message, enabled switch, hourly submission limit, and 1–20 +fields. Supported field types are short text, email, long text, checkbox, and +select. Field IDs are stable lowercase kebab-case names; changing an ID creates +a different value in future submissions. + +The submission inbox stores the exact field definition that accepted every +submission. Editing a form therefore never changes the labels or interpretation +of a historic response. Deleting a form deliberately deletes its submission +inbox in the same transaction. + +All administrator routes require the exact `admin` role: + +| Method | Path | Purpose | +|---|---|---| +| `GET` / `POST` | `/api/forms` | List or create forms | +| `GET` / `PUT` / `DELETE` | `/api/forms/{id}` | Read, replace, or remove a form | +| `GET` | `/api/forms/{id}/submissions` | Read its paginated submission inbox | + +## Public integration + +Only the following two exact paths are public. A form ID is a 32-character, +lowercase hexadecimal opaque identifier, not a name or a filesystem path. + +```text +GET /api/forms/public?id= +POST /api/forms/submit +``` + +The public definition contains only the fields needed to render the form: +`id`, `name`, `description`, `fields`, and `successMessage`. It never exposes +the creator or the submission quota. A client submits JSON in this shape: + +```json +{ + "formId": "0123456789abcdef0123456789abcdef", + "values": { + "email": "visitor@example.com", + "terms": true + } +} +``` + +The server rejects unknown field IDs, wrong JSON types, blank required values, +invalid email values, overlong text, and select values not declared by the +form. It trims text values before saving. The per-form hourly cap is a hard +global ceiling intended to keep a public endpoint bounded; a saturated form +returns `429`. For visitor-specific abuse controls, put a rate-limiting proxy +or CAPTCHA service in front of the public endpoint. + +## Privacy and events + +Form definitions, form configuration changes, and inbox reads are +administrator-only. A successful public submission stores its values only in +the form submission table. It emits `forms.submitted` transactionally with the +form ID and submission ID but **never values**; the event is not audit-marked. +This keeps PII out of `audit-log` and prevents generic webhook subscriptions +from forwarding submission content by mistake. diff --git a/plugins/forms/Cargo.toml b/plugins/forms/Cargo.toml new file mode 100644 index 0000000..4fa714c --- /dev/null +++ b/plugins/forms/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "forms" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Public forms and administrator submission inbox for Yeollin CMS" + +[lib] +path = "src/lib.rs" + +[dependencies] +yeollin-plugin = { workspace = true } +vespera = { workspace = true } +vespertide = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +axum = { workspace = true } +chrono = { workspace = true } +rand = { workspace = true } +sea-orm = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } diff --git a/plugins/forms/app/(forms)/page.tsx b/plugins/forms/app/(forms)/page.tsx new file mode 100644 index 0000000..3d0fb3d --- /dev/null +++ b/plugins/forms/app/(forms)/page.tsx @@ -0,0 +1,1111 @@ +'use client' + +import { Box, Flex, Grid, Text, VStack } from '@devup-ui/react' +import { useEffect, useState } from 'react' + +type FieldKind = 'text' | 'email' | 'textarea' | 'checkbox' | 'select' + +interface FormField { + id: string + label: string + kind: FieldKind + required: boolean + options: string[] + placeholder: string | null +} + +interface FormDefinition { + id: string + name: string + description: string + fields: FormField[] + successMessage: string + enabled: boolean + maxSubmissionsPerHour: number + createdBy: string + createdAt: string + updatedAt: string +} + +interface Submission { + id: string + formId: string + formName: string + fields: FormField[] + values: Record + createdAt: string +} + +interface SubmissionPage { + submissions: Submission[] + total: number + page: number + pageSize: number +} + +interface DraftField { + id: string + label: string + kind: FieldKind + required: boolean + placeholder: string + options: string +} + +interface FormDraft { + name: string + description: string + fields: DraftField[] + successMessage: string + enabled: boolean + maxSubmissionsPerHour: number +} + +class RequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message) + } +} + +const EMPTY_DRAFT: FormDraft = { + name: '', + description: '', + fields: [newDraftField('name', 'Your name')], + successMessage: 'Thanks - we received your response.', + enabled: true, + maxSubmissionsPerHour: 100, +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseField(value: unknown): FormField | null { + if (!isRecord(value)) return null + if ( + typeof value.id !== 'string' || + typeof value.label !== 'string' || + !isFieldKind(value.kind) || + typeof value.required !== 'boolean' || + !Array.isArray(value.options) || + !value.options.every((option) => typeof option === 'string') || + (value.placeholder !== null && typeof value.placeholder !== 'string') + ) { + return null + } + return { + id: value.id, + label: value.label, + kind: value.kind, + required: value.required, + options: value.options, + placeholder: value.placeholder, + } +} + +function isFieldKind(value: unknown): value is FieldKind { + return ( + value === 'text' || + value === 'email' || + value === 'textarea' || + value === 'checkbox' || + value === 'select' + ) +} + +function parseForm(value: unknown): FormDefinition | null { + if (!isRecord(value) || !Array.isArray(value.fields)) return null + const fields = value.fields.map(parseField) + if ( + fields.some((field) => field === null) || + typeof value.id !== 'string' || + typeof value.name !== 'string' || + typeof value.description !== 'string' || + typeof value.successMessage !== 'string' || + typeof value.enabled !== 'boolean' || + typeof value.maxSubmissionsPerHour !== 'number' || + typeof value.createdBy !== 'string' || + typeof value.createdAt !== 'string' || + typeof value.updatedAt !== 'string' + ) { + return null + } + return { + id: value.id, + name: value.name, + description: value.description, + fields: fields.filter((field): field is FormField => field !== null), + successMessage: value.successMessage, + enabled: value.enabled, + maxSubmissionsPerHour: value.maxSubmissionsPerHour, + createdBy: value.createdBy, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + } +} + +function parseSubmission(value: unknown): Submission | null { + if ( + !isRecord(value) || + !Array.isArray(value.fields) || + !isRecord(value.values) + ) { + return null + } + const fields = value.fields.map(parseField) + if ( + fields.some((field) => field === null) || + typeof value.id !== 'string' || + typeof value.formId !== 'string' || + typeof value.formName !== 'string' || + typeof value.createdAt !== 'string' + ) { + return null + } + return { + id: value.id, + formId: value.formId, + formName: value.formName, + fields: fields.filter((field): field is FormField => field !== null), + values: value.values, + createdAt: value.createdAt, + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, init) + const body = (await response.json().catch(() => null)) as unknown + if (!response.ok) { + const message = + isRecord(body) && typeof body.error === 'string' + ? body.error + : 'The request could not be completed.' + throw new RequestError(message, response.status) + } + return body +} + +async function loadForms(): Promise { + const value = await request('/api/forms') + if (!isRecord(value) || !Array.isArray(value.forms)) { + throw new Error('The server returned invalid form data.') + } + return value.forms + .map(parseForm) + .filter((form): form is FormDefinition => form !== null) +} + +async function loadSubmissions(formId: string): Promise { + const value = await request(`/api/forms/${formId}/submissions?pageSize=50`) + if (!isRecord(value) || !Array.isArray(value.submissions)) { + throw new Error('The server returned invalid submission data.') + } + return { + submissions: value.submissions + .map(parseSubmission) + .filter((submission): submission is Submission => submission !== null), + total: typeof value.total === 'number' ? value.total : 0, + page: typeof value.page === 'number' ? value.page : 1, + pageSize: typeof value.pageSize === 'number' ? value.pageSize : 50, + } +} + +function newDraftField(id = '', label = ''): DraftField { + return { + id, + label, + kind: 'text', + required: false, + placeholder: '', + options: '', + } +} + +function draftFromForm(form: FormDefinition): FormDraft { + return { + name: form.name, + description: form.description, + fields: form.fields.map((field) => ({ + id: field.id, + label: field.label, + kind: field.kind, + required: field.required, + placeholder: field.placeholder ?? '', + options: field.options.join('\n'), + })), + successMessage: form.successMessage, + enabled: form.enabled, + maxSubmissionsPerHour: form.maxSubmissionsPerHour, + } +} + +function wireFields(fields: DraftField[]): FormField[] { + return fields.map((field) => ({ + id: field.id, + label: field.label, + kind: field.kind, + required: field.required, + placeholder: field.placeholder.trim() === '' ? null : field.placeholder, + options: + field.kind === 'select' + ? field.options + .split('\n') + .map((option) => option.trim()) + .filter((option) => option !== '') + : [], + })) +} + +function formatDate(value: string): string { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 'Unknown time' : date.toLocaleString() +} + +function displayValue(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'boolean') return value ? 'Yes' : 'No' + return JSON.stringify(value) +} + +export default function FormsPage() { + const [forms, setForms] = useState([]) + const [draft, setDraft] = useState(EMPTY_DRAFT) + const [editingId, setEditingId] = useState('') + const [submissions, setSubmissions] = useState< + Record + >({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [busyId, setBusyId] = useState('') + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [forbidden, setForbidden] = useState(false) + + function refresh() { + setLoading(true) + void loadForms() + .then((nextForms) => { + setForms(nextForms) + setError('') + setForbidden(false) + }) + .catch((cause: unknown) => { + setError( + cause instanceof Error ? cause.message : 'Could not load forms.', + ) + setForbidden(cause instanceof RequestError && cause.status === 403) + }) + .finally(() => setLoading(false)) + } + + useEffect(() => { + let cancelled = false + void loadForms() + .then((nextForms) => { + if (cancelled) return + setForms(nextForms) + setError('') + setForbidden(false) + }) + .catch((cause: unknown) => { + if (cancelled) return + setError( + cause instanceof Error ? cause.message : 'Could not load forms.', + ) + setForbidden(cause instanceof RequestError && cause.status === 403) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + function updateField(index: number, patch: Partial) { + setDraft((current) => ({ + ...current, + fields: current.fields.map((field, fieldIndex) => + fieldIndex === index ? { ...field, ...patch } : field, + ), + })) + } + + function beginEdit(form: FormDefinition) { + setEditingId(form.id) + setDraft(draftFromForm(form)) + setError('') + setNotice( + 'Editing the live field definition. Existing submissions keep their original snapshot.', + ) + } + + function resetDraft() { + setEditingId('') + setDraft({ ...EMPTY_DRAFT, fields: [newDraftField('name', 'Your name')] }) + setError('') + setNotice('') + } + + async function save(event: React.FormEvent) { + event.preventDefault() + setSaving(true) + setError('') + const payload = { + ...draft, + fields: wireFields(draft.fields), + } + try { + const path = editingId === '' ? '/api/forms' : `/api/forms/${editingId}` + const method = editingId === '' ? 'POST' : 'PUT' + await request(path, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + setNotice(editingId === '' ? 'Form created.' : 'Form updated.') + resetDraft() + refresh() + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not save the form.', + ) + } finally { + setSaving(false) + } + } + + async function remove(form: FormDefinition) { + if (!window.confirm(`Delete "${form.name}" and all of its submissions?`)) + return + setBusyId(form.id) + setError('') + try { + await request(`/api/forms/${form.id}`, { method: 'DELETE' }) + if (editingId === form.id) resetDraft() + setNotice('Form and its submissions were deleted.') + refresh() + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not delete the form.', + ) + } finally { + setBusyId('') + } + } + + async function toggleSubmissions(form: FormDefinition) { + if (submissions[form.id] !== undefined) { + setSubmissions((current) => { + const next = { ...current } + delete next[form.id] + return next + }) + return + } + setBusyId(form.id) + setError('') + try { + const page = await loadSubmissions(form.id) + setSubmissions((current) => ({ ...current, [form.id]: page })) + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not load submissions.', + ) + } finally { + setBusyId('') + } + } + + async function copyPublicId(id: string) { + try { + await navigator.clipboard.writeText(id) + setNotice('Public form id copied to the clipboard.') + setError('') + } catch { + setError('Could not copy the public form id.') + } + } + + return ( + + + + + Forms + + Build validated public forms and review submitted responses. + + + + {loading ? 'Refreshing...' : 'Refresh'} + + + + {forbidden ? ( + + Administrator access is required to manage forms and submissions. + + ) : null} + {error !== '' ? {error} : null} + {notice !== '' ? {notice} : null} + + {!forbidden ? ( + + + + + + + {editingId === '' ? 'Create a form' : 'Edit form'} + + + Public clients load enabled forms from + /api/forms/public?id=… and submit to /api/forms/submit. + + + {editingId !== '' ? ( + + Cancel editing + + ) : null} + + + + + + setDraft((current) => ({ ...current, name: value })) + } + placeholder="Contact us" + required + value={draft.name} + /> + + + + setDraft((current) => ({ + ...current, + maxSubmissionsPerHour: Number(value), + })) + } + required + type="number" + value={String(draft.maxSubmissionsPerHour)} + /> + + + +