Allow anonymous responses to published forms

This commit is contained in:
2026-06-13 11:05:41 -06:00
parent 9cd667f0a8
commit 4d45516c13
11 changed files with 45 additions and 54 deletions
+1 -1
View File
@@ -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
+6 -7
View File
@@ -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).
-7
View File
@@ -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 });
-3
View File
@@ -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(() => ({}));
+3 -6
View File
@@ -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);
+4 -24
View File
@@ -403,7 +403,6 @@ export default function Builder({ form: initial, members, currentUserId, publicF
members={members}
currentUserId={currentUserId}
publicFormsOrigin={publicFormsOrigin}
onSettings={(s) => update({ settings: s })}
/>
</div>
)}
@@ -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<string[]>(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 (
<div className="grid md:grid-cols-2 gap-5">
<div className="card p-5 space-y-4">
@@ -1197,21 +1189,9 @@ function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings
<Input readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
<Button variant="outline" onClick={() => navigator.clipboard.writeText(url)}>Copy</Button>
</div>
<div className="space-y-2">
<div className="panel-label">Who can fill</div>
{([
["workspace", "Workspace", "Anyone signed in"],
["public", "Public", "Anyone with the link"],
] as const).map(([v, l, sub]) => (
<label key={v} className="flex gap-2 items-start cursor-pointer">
<input type="radio" checked={vis === v} onChange={() => changeVisibility(v)} className="mt-1" />
<div>
<div className="text-sm font-medium">{l}</div>
<div className="text-xs text-[rgb(var(--muted))]">{sub}</div>
</div>
</label>
))}
</div>
<p className="text-xs text-[rgb(var(--muted))]">
Anyone with the link can fill a published form. Form editing and responses remain restricted.
</p>
</div>
<div className="card p-5 space-y-3">
+2 -2
View File
@@ -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({
+16
View File
@@ -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);
});
});
+9
View File
@@ -0,0 +1,9 @@
export function canFillForm({
published,
isOwnerOrAdmin,
}: {
published: boolean;
isOwnerOrAdmin: boolean;
}): boolean {
return published || isOwnerOrAdmin;
}
+1 -1
View File
@@ -304,7 +304,7 @@ const TOOLS: Record<string, (ctx: Ctx, args: Record<string, unknown>) => Promise
const rawFields = Array.isArray(args.fields) ? (args.fields as Partial<Field>[]) : [];
// 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,
+3 -3
View File
@@ -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 },
{