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/*
|
/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
|
## Authentik Setup
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { NextResponse, type NextRequest } from "next/server";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
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 { driver, currentDriver } from "@/lib/storage";
|
||||||
import { consume, clientIp } from "@/lib/ratelimit";
|
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 } });
|
const form = await prisma.form.findUnique({ where: { id: formId } });
|
||||||
if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!form) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
const settings = parseSettings(form.settings);
|
|
||||||
const fields = parseFields(form.fields);
|
const fields = parseFields(form.fields);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isOwnerOrAdmin = !!session?.user && (session.user.id === form.ownerId || session.user.role === "admin");
|
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
|
// Respondents can upload to published forms; owners/admins can also upload
|
||||||
// upload to draft forms too (branding images uploaded during editing).
|
// to drafts while editing.
|
||||||
if (!form.published && !isOwnerOrAdmin) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!canFillForm({ published: form.published, isOwnerOrAdmin })) {
|
||||||
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce per-form max size (cap by the most permissive file field, or default).
|
// 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 { nanoid } from "nanoid";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { parseSettings } from "@/lib/forms";
|
|
||||||
|
|
||||||
const COOKIE = "fb_partial";
|
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 } });
|
const form = await prisma.form.findUnique({ where: { id } });
|
||||||
if (!form || !form.published) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
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 body = await req.json().catch(() => ({}));
|
||||||
const data = body.data;
|
const data = body.data;
|
||||||
if (!data || typeof data !== "object") return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
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 settings = parseSettings(form.settings);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
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 { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { canFillForm } from "@/lib/form-access";
|
||||||
import { parseFields, parseSettings } from "@/lib/forms";
|
import { parseFields, parseSettings } from "@/lib/forms";
|
||||||
import FormFiller from "./FormFiller";
|
import FormFiller from "./FormFiller";
|
||||||
|
|
||||||
@@ -24,11 +25,7 @@ export default async function PublicForm({
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const isOwnerOrAdmin = session?.user && (session.user.id === form.ownerId || session.user.role === "admin");
|
const isOwnerOrAdmin = session?.user && (session.user.id === form.ownerId || session.user.role === "admin");
|
||||||
|
|
||||||
if (!form.published && !isOwnerOrAdmin) notFound();
|
if (!canFillForm({ published: form.published, isOwnerOrAdmin: !!isOwnerOrAdmin })) notFound();
|
||||||
|
|
||||||
if ((settings.visibility ?? "workspace") === "workspace" && !session?.user) {
|
|
||||||
redirect(`/signin?from=/f/${slug}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = parseFields(form.fields);
|
const fields = parseFields(form.fields);
|
||||||
|
|
||||||
|
|||||||
@@ -403,7 +403,6 @@ export default function Builder({ form: initial, members, currentUserId, publicF
|
|||||||
members={members}
|
members={members}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
publicFormsOrigin={publicFormsOrigin}
|
publicFormsOrigin={publicFormsOrigin}
|
||||||
onSettings={(s) => update({ settings: s })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1171,12 +1170,10 @@ function SettingsConfig({ settings, description, formId, onChange }: {
|
|||||||
|
|
||||||
// ── Share tab ──────────────────────────────────────────────────────────────
|
// ── Share tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings }: {
|
function ShareTab({ form, members, currentUserId, publicFormsOrigin }: {
|
||||||
form: FormData; members: Member[]; currentUserId: string;
|
form: FormData; members: Member[]; currentUserId: string;
|
||||||
publicFormsOrigin: string;
|
publicFormsOrigin: string;
|
||||||
onSettings: (s: FormSettings) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [vis, setVis] = useState(form.settings.visibility ?? "workspace");
|
|
||||||
const [viewerIds, setViewerIds] = useState<string[]>(form.viewerIds);
|
const [viewerIds, setViewerIds] = useState<string[]>(form.viewerIds);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
@@ -1184,11 +1181,6 @@ function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings
|
|||||||
[form.slug, publicFormsOrigin],
|
[form.slug, publicFormsOrigin],
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeVisibility = (v: "workspace" | "public") => {
|
|
||||||
setVis(v);
|
|
||||||
onSettings({ ...form.settings, visibility: v });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid md:grid-cols-2 gap-5">
|
<div className="grid md:grid-cols-2 gap-5">
|
||||||
<div className="card p-5 space-y-4">
|
<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()} />
|
<Input readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
|
||||||
<Button variant="outline" onClick={() => navigator.clipboard.writeText(url)}>Copy</Button>
|
<Button variant="outline" onClick={() => navigator.clipboard.writeText(url)}>Copy</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-[rgb(var(--muted))]">
|
||||||
<div className="panel-label">Who can fill</div>
|
Anyone with the link can fill a published form. Form editing and responses remain restricted.
|
||||||
{([
|
</p>
|
||||||
["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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-5 space-y-3">
|
<div className="card p-5 space-y-3">
|
||||||
|
|||||||
+2
-2
@@ -25,7 +25,7 @@ export async function createForm() {
|
|||||||
title: "Untitled form",
|
title: "Untitled form",
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
fields: "[]",
|
fields: "[]",
|
||||||
settings: JSON.stringify({ visibility: "workspace" } satisfies FormSettings),
|
settings: JSON.stringify({ visibility: "public" } satisfies FormSettings),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Seed version 1.
|
// Seed version 1.
|
||||||
@@ -73,7 +73,7 @@ export async function createFromTemplate(templateId: string) {
|
|||||||
description: tpl.form.description ?? null,
|
description: tpl.form.description ?? null,
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
fields: JSON.stringify(newFields),
|
fields: JSON.stringify(newFields),
|
||||||
settings: JSON.stringify(tpl.form.settings ?? { visibility: "workspace" }),
|
settings: JSON.stringify(tpl.form.settings ?? { visibility: "public" }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await snapshotForm({
|
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>[]) : [];
|
const rawFields = Array.isArray(args.fields) ? (args.fields as Partial<Field>[]) : [];
|
||||||
// Always generate the id ourselves — never trust an id supplied via MCP.
|
// 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 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({
|
const form = await prisma.form.create({
|
||||||
data: {
|
data: {
|
||||||
slug: nanoid(10), title, description, ownerId: ctx.userId,
|
slug: nanoid(10), title, description, ownerId: ctx.userId,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const TEMPLATES: Template[] = [
|
|||||||
description: "Net promoter score with a follow-up.",
|
description: "Net promoter score with a follow-up.",
|
||||||
form: {
|
form: {
|
||||||
title: "How likely are you to recommend us?",
|
title: "How likely are you to recommend us?",
|
||||||
settings: { visibility: "workspace" },
|
settings: { visibility: "public" },
|
||||||
fields: [
|
fields: [
|
||||||
{ id: fid("score"), type: "rating", label: "0 — 10", max: 10, required: true },
|
{ 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?" },
|
{ 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.",
|
description: "Attending, plus-ones, dietary notes.",
|
||||||
form: {
|
form: {
|
||||||
title: "Will you be there?",
|
title: "Will you be there?",
|
||||||
settings: { visibility: "workspace" },
|
settings: { visibility: "public" },
|
||||||
fields: [
|
fields: [
|
||||||
{ id: fid("name"), type: "short_text", label: "Your name", required: true },
|
{ 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.",
|
description: "Repro steps, severity, expected vs actual.",
|
||||||
form: {
|
form: {
|
||||||
title: "Report a bug",
|
title: "Report a bug",
|
||||||
settings: { visibility: "workspace" },
|
settings: { visibility: "public" },
|
||||||
fields: [
|
fields: [
|
||||||
{ id: fid("title"), type: "short_text", label: "What went wrong?", required: true },
|
{ id: fid("title"), type: "short_text", label: "What went wrong?", required: true },
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user