+ >
+ )
+}
diff --git a/app/(app)/forms/forms-client.tsx b/app/(app)/forms/forms-client.tsx
new file mode 100644
index 0000000..eedbd6b
--- /dev/null
+++ b/app/(app)/forms/forms-client.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import Link from 'next/link'
+import { FileText, Plus } from 'lucide-react'
+import { PageHeader, PageTitle } from '@/components/ui/page-title'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
+
+type FormRow = {
+ id: string
+ slug: string
+ title: string
+ kind: string
+ visibility: string
+ status: string
+ version: number
+ created_at: string
+}
+
+const VIS_LABEL: Record = {
+ members: 'Members only',
+ public_auth: 'Signed in',
+ public_anon: 'Public',
+}
+
+export function FormsClient({ forms }: { forms: FormRow[] }) {
+ return (
+ <>
+
+ Forms & waivers
+
+
+
+
+ {forms.length === 0 ? (
+
+
+
+
+
+ No forms yet
+
+ Build a custom form or a signable waiver. Members-only or public, your choice.
+
+
+
+
+ ) : (
+
+ )
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 70d22fb..370fcf6 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -422,9 +422,10 @@ forms + waivers).
### Forms & waivers (migration 026 — Phase 1: schema + RLS)
-`forms` and `form_submissions` back a custom form/waiver builder. Phase 1 is
-schema + RLS only; server actions, the builder UI, the public `/f/[slug]` page,
-onboarding integration, and retro-linking are later phases. Security shape:
+`forms` and `form_submissions` back a custom form/waiver builder. Phases 1-3
+are done (schema + RLS; server actions in `lib/actions/forms.ts`; the builder
+UI under `/forms` + the public `/f/[slug]` page). Onboarding integration and
+automatic retro-linking are Phases 4-5. Security shape:
all submissions (anonymous, public-authenticated, or members) are written by a
single validated server action using the service client AFTER server-side
schema validation and snapshotting (same pattern as `finishOnboarding`); the
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index 1f80d24..b72c7a3 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -4,7 +4,7 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
-## 2026-05-16 (pass 18) — Forms + waivers Phases 1-2 (schema/RLS + server actions), LOCAL, awaiting deploy approval
+## 2026-05-16 (pass 18) — Forms + waivers Phases 1-3 (schema/RLS + server actions + builder UI), LOCAL, awaiting deploy approval
Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec vitest run` gate pending in this entry's session.
@@ -37,10 +37,20 @@ Audited the migration before continuing. Three fixes applied: dropped redundant
- `types/database.ts`: typed `user_has_permission` + `user_effective_roles` (were stale since 023). `lib/actions/index.ts` re-exports forms. `__tests__/forms.test.ts` (14 tests). API_REFERENCE updated.
- Gate: `pnpm exec vitest run` 285/285; `pnpm build` exit 0, clean.
+### Forms + waivers Phase 3 (this session) — builder UI + public page, LOCAL
+
+- `components/forms/form-renderer.tsx`: one controlled renderer for all 8 field types, shared by the builder preview and the public page so they cannot drift.
+- `components/forms/form-builder.tsx`: the easy builder (locked first-class priority). Label-only field creation (keys auto-derived + de-duped at save, never typed), add via one-click type buttons, up/down reorder buttons (no integer order field), inline options editor, live preview pane. Edit mode adds publish/close/draft/delete (via `useConfirm`) and a copy-public-link affordance.
+- Pages (server-gated by `requireFormsManagerPage` in `lib/forms-guard.ts` — forms.manage via the same RPC, redirect non-managers; RLS still the real boundary): `/forms` directory (status/visibility badges, Empty state), `/forms/new`, `/forms/[id]/edit`, `/forms/[id]/results` (table + client CSV download via `exportFormResultsCsv`).
+- Public `app/f/[slug]/page.tsx` (top-level, outside `(app)`): `getPublicForm` (service client, published + public-visibility only — members-only forms are NOT exposed here; their schema stays in-app), waiver legal text + consent, optional email for anon, signed-in gate for `public_auth`, thank-you state.
+- Sidebar: "Forms & waivers" NavLink in the Admin block. Toasts use `sonner` (matched the codebase convention; the custom use-toast has no mounted Toaster). New `getPublicForm` + `formSlug` export; API_REFERENCE updated.
+- Gate: `pnpm exec vitest run` 285/285; `pnpm build` exit 0; `/forms`, `/forms/[id]`, `/forms/new`, `/f/[slug]` all compiled.
+- NOT browser-verified (no live browser this session): smoke-test the builder (add/reorder/remove fields, live preview, create + edit + publish + close + delete), the results CSV download, and a public submission for each visibility (anon, public_auth signed-out gate, waiver consent).
+
### Open / next
-- **Awaiting user approval to deploy** (push to `main` = prod). Phase 1 commits `3a2d3ca`+`66dd9b7` and Phase 2 are LOCAL only; do NOT push without explicit go. Migration `026` ships with Phase 1.
-- Phase 2 not browser-tested (no UI yet — actions only). Phases 3-5 not started: builder UI + `/forms` + public `/f/[slug]`; onboarding form-step + required-waiver enforcement; automatic retro-link hooks (join/signup/addMember/import + email verification) — `linkSubmissionsForMember` primitive already exists.
+- **Awaiting user approval to deploy** (push to `main` = prod). Phases 1-3 are all LOCAL (commits `3a2d3ca`, `66dd9b7`, Phase 2, Phase 3); do NOT push without explicit go. Migration `026` ships with the first push.
+- Phases 4-5 not started: onboarding form-step + required-waiver enforcement + auto-satisfy when a matching signed waiver exists; automatic retro-link hooks (join/signup/addMember/import + email verification) — `linkSubmissionsForMember` primitive already exists. Phase 3 is code-complete but NOT browser-verified (see Phase 3 smoke-test list above).
- After an approved push: watch the Actions run, then smoke-test that `026` applied (`_migrations_applied` row) and a space's board has `forms.manage`.
---
diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts
index 0dab154..d87e8af 100644
--- a/lib/actions/forms.ts
+++ b/lib/actions/forms.ts
@@ -19,6 +19,7 @@ import {
formIdSchema,
submitFormSchema,
linkSubmissionsSchema,
+ getPublicFormSchema,
} from '@/lib/validations'
import { parseFormSchema, validateAnswers } from '@/lib/forms-schema'
@@ -393,6 +394,29 @@ export async function submitForm(input: unknown) {
return { success: true as const }
}
+/**
+ * Public read for the /f/[slug] page. Service client (anon has no grant on
+ * `forms`). Returns only a published form and only public-safe columns.
+ */
+export async function getPublicForm(input: unknown) {
+ const v = parseInput(getPublicFormSchema, input)
+ if (!v.ok) return { error: v.error }
+
+ const admin = createAdminClient()
+ const { data: form } = await admin
+ .from('forms')
+ .select('id, slug, title, description, kind, visibility, schema, legal_text, version, status')
+ .eq('slug', v.data.slug)
+ .single()
+
+ // Members-only forms are never served on the public page (that would expose
+ // their schema to anyone). They are filled from inside the app.
+ if (!form || form.status !== 'published' || form.visibility === 'members') {
+ return { error: 'This form is not available.' }
+ }
+ return { data: form }
+}
+
/**
* Retro-link prior anonymous submissions to a member by matching email.
*
diff --git a/lib/forms-guard.ts b/lib/forms-guard.ts
new file mode 100644
index 0000000..dd3e31b
--- /dev/null
+++ b/lib/forms-guard.ts
@@ -0,0 +1,32 @@
+import { redirect } from 'next/navigation'
+import { createClient } from '@/lib/supabase/server'
+
+// Server-component guard for the /forms management pages. Mirrors the
+// /settings server gate: resolve the member, check forms.manage via the
+// same RPC the forms RLS uses, and redirect non-managers before any data
+// is fetched. The forms RLS independently enforces this; the redirect is
+// for UX, not the security boundary.
+export async function requireFormsManagerPage() {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+ if (!user) redirect('/login')
+
+ const { data: member } = await supabase
+ .from('space_members')
+ .select('id, space_id, role, user_id, display_name')
+ .eq('user_id', user.id)
+ .in('status', ['current', 'unverified', 'late'])
+ .single()
+ if (!member) redirect('/login')
+
+ const { data: allowed } = await supabase.rpc('user_has_permission', {
+ uid: user.id,
+ sid: member.space_id,
+ perm: 'forms.manage',
+ })
+ if (!allowed) redirect('/dashboard')
+
+ return { supabase, member }
+}
diff --git a/lib/validations.ts b/lib/validations.ts
index 5d10f3c..dfe5f36 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -569,7 +569,7 @@ export const formKinds = ['form', 'waiver'] as const
export const formVisibilities = ['public_anon', 'public_auth', 'members'] as const
export const formStatuses = ['draft', 'published', 'closed'] as const
-const formSlug = z
+export const formSlug = z
.string()
.min(1, 'Slug is required')
.max(80, 'Slug must be 80 characters or fewer')
@@ -666,6 +666,8 @@ export const linkSubmissionsSchema = z.object({
email: emailField(),
})
+export const getPublicFormSchema = z.object({ slug: formSlug })
+
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')