Allow anonymous responses to published forms
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export function canFillForm({
|
||||
published,
|
||||
isOwnerOrAdmin,
|
||||
}: {
|
||||
published: boolean;
|
||||
isOwnerOrAdmin: boolean;
|
||||
}): boolean {
|
||||
return published || isOwnerOrAdmin;
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user