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)} + /> + + + +