From 4d45516c13fadddf90465b9ff2e4be8d2000b04f Mon Sep 17 00:00:00 2001 From: "Zachariah K. Sharma" Date: Sat, 13 Jun 2026 11:05:41 -0600 Subject: [PATCH] Allow anonymous responses to published forms --- README.md | 2 +- src/app/api/files/upload/route.ts | 13 ++++++------ src/app/api/forms/[id]/partial/route.ts | 7 ------- src/app/api/forms/[id]/submit/route.ts | 3 --- src/app/f/[slug]/page.tsx | 9 +++----- src/components/builder/Builder.tsx | 28 ++++--------------------- src/lib/actions.ts | 4 ++-- src/lib/form-access.test.ts | 16 ++++++++++++++ src/lib/form-access.ts | 9 ++++++++ src/lib/mcp.ts | 2 +- src/lib/templates.ts | 6 +++--- 11 files changed, 45 insertions(+), 54 deletions(-) create mode 100644 src/lib/form-access.test.ts create mode 100644 src/lib/form-access.ts diff --git a/README.md b/README.md index e2d2c78..22f3fc2 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ The public proxy host must forward these paths to the app: /api/files/* ``` -Published forms intended for `forms.vyntehome.com` must use **Public** visibility. Workspace-only forms require an authenticated session and should be opened on the internal builder domain. +Anyone with the link can respond to a published form at `forms.vyntehome.com` without signing in. Form editing and response management remain authenticated on the internal builder domain. ## Authentik Setup diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts index 6e3c558..6ee5dec 100644 --- a/src/app/api/files/upload/route.ts +++ b/src/app/api/files/upload/route.ts @@ -11,7 +11,8 @@ import { NextResponse, type NextRequest } from "next/server"; import { nanoid } from "nanoid"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; -import { parseFields, parseSettings } from "@/lib/forms"; +import { canFillForm } from "@/lib/form-access"; +import { parseFields } from "@/lib/forms"; import { driver, currentDriver } from "@/lib/storage"; import { consume, clientIp } from "@/lib/ratelimit"; @@ -41,16 +42,14 @@ export async function POST(req: NextRequest) { const form = await prisma.form.findUnique({ where: { id: formId } }); if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 }); - const settings = parseSettings(form.settings); const fields = parseFields(form.fields); const session = await auth(); const isOwnerOrAdmin = !!session?.user && (session.user.id === form.ownerId || session.user.role === "admin"); - // Public form fillers can only upload to published forms; owners/admins can - // upload to draft forms too (branding images uploaded during editing). - if (!form.published && !isOwnerOrAdmin) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // Respondents can upload to published forms; owners/admins can also upload + // to drafts while editing. + if (!canFillForm({ published: form.published, isOwnerOrAdmin })) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Enforce per-form max size (cap by the most permissive file field, or default). diff --git a/src/app/api/forms/[id]/partial/route.ts b/src/app/api/forms/[id]/partial/route.ts index 0623e39..d089112 100644 --- a/src/app/api/forms/[id]/partial/route.ts +++ b/src/app/api/forms/[id]/partial/route.ts @@ -13,7 +13,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { nanoid } from "nanoid"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; -import { parseSettings } from "@/lib/forms"; const COOKIE = "fb_partial"; @@ -51,12 +50,6 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: const form = await prisma.form.findUnique({ where: { id } }); if (!form || !form.published) return NextResponse.json({ error: "Not found" }, { status: 404 }); - const settings = parseSettings(form.settings); - if ((settings.visibility ?? "workspace") === "workspace") { - const session = await auth(); - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const body = await req.json().catch(() => ({})); const data = body.data; if (!data || typeof data !== "object") return NextResponse.json({ error: "Bad request" }, { status: 400 }); diff --git a/src/app/api/forms/[id]/submit/route.ts b/src/app/api/forms/[id]/submit/route.ts index 7ee2870..39ffb0e 100644 --- a/src/app/api/forms/[id]/submit/route.ts +++ b/src/app/api/forms/[id]/submit/route.ts @@ -28,9 +28,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const settings = parseSettings(form.settings); const session = await auth(); - if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } const body = await req.json().catch(() => ({})); diff --git a/src/app/f/[slug]/page.tsx b/src/app/f/[slug]/page.tsx index f5776d3..4d75cec 100644 --- a/src/app/f/[slug]/page.tsx +++ b/src/app/f/[slug]/page.tsx @@ -1,6 +1,7 @@ -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db"; +import { canFillForm } from "@/lib/form-access"; import { parseFields, parseSettings } from "@/lib/forms"; import FormFiller from "./FormFiller"; @@ -24,11 +25,7 @@ export default async function PublicForm({ const session = await auth(); const isOwnerOrAdmin = session?.user && (session.user.id === form.ownerId || session.user.role === "admin"); - if (!form.published && !isOwnerOrAdmin) notFound(); - - if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) { - redirect(`/signin?from=/f/${slug}`); - } + if (!canFillForm({ published: form.published, isOwnerOrAdmin: !!isOwnerOrAdmin })) notFound(); const fields = parseFields(form.fields); diff --git a/src/components/builder/Builder.tsx b/src/components/builder/Builder.tsx index 93ed1d1..ecd61a7 100644 --- a/src/components/builder/Builder.tsx +++ b/src/components/builder/Builder.tsx @@ -403,7 +403,6 @@ export default function Builder({ form: initial, members, currentUserId, publicF members={members} currentUserId={currentUserId} publicFormsOrigin={publicFormsOrigin} - onSettings={(s) => update({ settings: s })} /> )} @@ -1171,12 +1170,10 @@ function SettingsConfig({ settings, description, formId, onChange }: { // ── Share tab ────────────────────────────────────────────────────────────── -function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings }: { +function ShareTab({ form, members, currentUserId, publicFormsOrigin }: { form: FormData; members: Member[]; currentUserId: string; publicFormsOrigin: string; - onSettings: (s: FormSettings) => void; }) { - const [vis, setVis] = useState(form.settings.visibility ?? "workspace"); const [viewerIds, setViewerIds] = useState(form.viewerIds); const [pending, startTransition] = useTransition(); const url = useMemo( @@ -1184,11 +1181,6 @@ function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings [form.slug, publicFormsOrigin], ); - const changeVisibility = (v: "workspace" | "public") => { - setVis(v); - onSettings({ ...form.settings, visibility: v }); - }; - return (
@@ -1197,21 +1189,9 @@ function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings e.currentTarget.select()} />
-
-
Who can fill
- {([ - ["workspace", "Workspace", "Anyone signed in"], - ["public", "Public", "Anyone with the link"], - ] as const).map(([v, l, sub]) => ( - - ))} -
+

+ Anyone with the link can fill a published form. Form editing and responses remain restricted. +

diff --git a/src/lib/actions.ts b/src/lib/actions.ts index ee16a7e..d09c935 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -25,7 +25,7 @@ export async function createForm() { title: "Untitled form", ownerId: user.id, fields: "[]", - settings: JSON.stringify({ visibility: "workspace" } satisfies FormSettings), + settings: JSON.stringify({ visibility: "public" } satisfies FormSettings), }, }); // Seed version 1. @@ -73,7 +73,7 @@ export async function createFromTemplate(templateId: string) { description: tpl.form.description ?? null, ownerId: user.id, fields: JSON.stringify(newFields), - settings: JSON.stringify(tpl.form.settings ?? { visibility: "workspace" }), + settings: JSON.stringify(tpl.form.settings ?? { visibility: "public" }), }, }); await snapshotForm({ diff --git a/src/lib/form-access.test.ts b/src/lib/form-access.test.ts new file mode 100644 index 0000000..f27b757 --- /dev/null +++ b/src/lib/form-access.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { canFillForm } from "./form-access"; + +describe("form fill access", () => { + test("allows anonymous respondents to fill published forms", () => { + expect(canFillForm({ published: true, isOwnerOrAdmin: false })).toBe(true); + }); + + test("keeps drafts private from anonymous respondents", () => { + expect(canFillForm({ published: false, isOwnerOrAdmin: false })).toBe(false); + }); + + test("allows owners and admins to preview drafts", () => { + expect(canFillForm({ published: false, isOwnerOrAdmin: true })).toBe(true); + }); +}); diff --git a/src/lib/form-access.ts b/src/lib/form-access.ts new file mode 100644 index 0000000..39fca2d --- /dev/null +++ b/src/lib/form-access.ts @@ -0,0 +1,9 @@ +export function canFillForm({ + published, + isOwnerOrAdmin, +}: { + published: boolean; + isOwnerOrAdmin: boolean; +}): boolean { + return published || isOwnerOrAdmin; +} diff --git a/src/lib/mcp.ts b/src/lib/mcp.ts index ab61780..22b7241 100644 --- a/src/lib/mcp.ts +++ b/src/lib/mcp.ts @@ -304,7 +304,7 @@ const TOOLS: Record) => Promise const rawFields = Array.isArray(args.fields) ? (args.fields as Partial[]) : []; // Always generate the id ourselves — never trust an id supplied via MCP. const fields: Field[] = rawFields.map((f) => ({ ...(f as Field), id: nanoid(8) })); - const settings: FormSettings = { visibility: "workspace" }; + const settings: FormSettings = { visibility: "public" }; const form = await prisma.form.create({ data: { slug: nanoid(10), title, description, ownerId: ctx.userId, diff --git a/src/lib/templates.ts b/src/lib/templates.ts index abde79e..35dffd2 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -37,7 +37,7 @@ export const TEMPLATES: Template[] = [ description: "Net promoter score with a follow-up.", form: { title: "How likely are you to recommend us?", - settings: { visibility: "workspace" }, + settings: { visibility: "public" }, fields: [ { id: fid("score"), type: "rating", label: "0 — 10", max: 10, required: true }, { id: fid("reason"), type: "long_text", label: "What's the main reason for your score?" }, @@ -51,7 +51,7 @@ export const TEMPLATES: Template[] = [ description: "Attending, plus-ones, dietary notes.", form: { title: "Will you be there?", - settings: { visibility: "workspace" }, + settings: { visibility: "public" }, fields: [ { id: fid("name"), type: "short_text", label: "Your name", required: true }, { @@ -106,7 +106,7 @@ export const TEMPLATES: Template[] = [ description: "Repro steps, severity, expected vs actual.", form: { title: "Report a bug", - settings: { visibility: "workspace" }, + settings: { visibility: "public" }, fields: [ { id: fid("title"), type: "short_text", label: "What went wrong?", required: true }, {