Support separate public forms domain

This commit is contained in:
Zachariah K. Sharma
2026-06-07 20:08:45 -06:00
parent 095a3fe879
commit 9cd667f0a8
9 changed files with 90 additions and 15 deletions
+2
View File
@@ -5,6 +5,7 @@ import { canEditForm, parseFields, parseSettings } from "@/lib/forms";
import { listAllTags } from "@/lib/tags";
import Builder from "@/components/builder/Builder";
import TagBar from "@/components/ui/TagBar";
import { publicFormsOrigin } from "@/lib/urls";
export default async function FormPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -48,6 +49,7 @@ export default async function FormPage({ params }: { params: Promise<{ id: strin
}}
members={members}
currentUserId={session.user.id}
publicFormsOrigin={publicFormsOrigin()}
/>
</div>
);
+2 -1
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/db";
import { createForm, createFromTemplate } from "@/lib/actions";
import { TEMPLATES } from "@/lib/templates";
import { listAllTags } from "@/lib/tags";
import { publicFormUrl } from "@/lib/urls";
import { Plus, FileText, Layers } from "lucide-react";
export default async function FormsList({ searchParams }: { searchParams: Promise<{ tag?: string }> }) {
@@ -129,7 +130,7 @@ export default async function FormsList({ searchParams }: { searchParams: Promis
{forms.map((f) => {
const canEdit = isAdmin || f.ownerId === userId;
const canSeeCount = canEdit || f.viewers.length > 0;
const href = canEdit ? `/app/forms/${f.id}` : `/f/${f.slug}`;
const href = canEdit ? `/app/forms/${f.id}` : publicFormUrl(f.slug);
const ownerLabel = f.owner.name || f.owner.email;
return (
+17 -11
View File
@@ -81,8 +81,8 @@ function fieldChipClass(type: FieldType): string {
// ── Main component ─────────────────────────────────────────────────────────
export default function Builder({ form: initial, members, currentUserId }: {
form: FormData; members: Member[]; currentUserId: string;
export default function Builder({ form: initial, members, currentUserId, publicFormsOrigin }: {
form: FormData; members: Member[]; currentUserId: string; publicFormsOrigin: string;
}) {
const [form, setForm] = useState<FormData>(initial);
const [selectedId, setSelectedId] = useState<string | null>(initial.fields[0]?.id ?? null);
@@ -225,6 +225,7 @@ export default function Builder({ form: initial, members, currentUserId }: {
{/* ── Top bar ── */}
<TopBar
form={form} saving={saving} savedAt={savedAt} tab={tab} setTab={setTab}
publicFormUrl={`${publicFormsOrigin}/f/${encodeURIComponent(form.slug)}`}
onTitleChange={(t) => update({ title: t })}
onTogglePublish={togglePublish}
onDelete={async () => {
@@ -401,6 +402,7 @@ export default function Builder({ form: initial, members, currentUserId }: {
form={form}
members={members}
currentUserId={currentUserId}
publicFormsOrigin={publicFormsOrigin}
onSettings={(s) => update({ settings: s })}
/>
</div>
@@ -411,8 +413,9 @@ export default function Builder({ form: initial, members, currentUserId }: {
// ── Top bar ────────────────────────────────────────────────────────────────
function TopBar({ form, saving, savedAt, tab, setTab, onTitleChange, onTogglePublish, onDelete }: {
function TopBar({ form, saving, savedAt, tab, setTab, publicFormUrl, onTitleChange, onTogglePublish, onDelete }: {
form: FormData; saving: boolean; savedAt: number | null; tab: Tab; setTab: (t: Tab) => void;
publicFormUrl: string;
onTitleChange: (t: string) => void; onTogglePublish: () => void; onDelete: () => void;
}) {
const statusText = saving ? "Saving…" : savedAt ? "Saved" : null;
@@ -466,7 +469,7 @@ function TopBar({ form, saving, savedAt, tab, setTab, onTitleChange, onTogglePub
<BarChart2 size={13} />
</Link>
{form.published && (
<a href={`/f/${form.slug}`} target="_blank" rel="noreferrer"
<a href={publicFormUrl} target="_blank" rel="noreferrer"
className="btn btn-ghost btn-sm btn-icon text-[rgb(var(--muted))]">
<ExternalLink size={13} />
</a>
@@ -1168,14 +1171,18 @@ function SettingsConfig({ settings, description, formId, onChange }: {
// ── Share tab ──────────────────────────────────────────────────────────────
function ShareTab({ form, members, currentUserId, onSettings }: {
function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings }: {
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(() => `${typeof window !== "undefined" ? window.location.origin : ""}/f/${form.slug}`, [form.slug]);
const url = useMemo(
() => `${publicFormsOrigin}/f/${encodeURIComponent(form.slug)}`,
[form.slug, publicFormsOrigin],
);
const changeVisibility = (v: "workspace" | "public") => {
setVis(v);
@@ -1231,15 +1238,14 @@ function ShareTab({ form, members, currentUserId, onSettings }: {
</Button>
</div>
<EmbedPanel slug={form.slug} />
<EmbedPanel slug={form.slug} publicFormsOrigin={publicFormsOrigin} />
</div>
);
}
function EmbedPanel({ slug }: { slug: string }) {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const iframe = `<iframe src="${origin}/f/${slug}?embed=1" width="100%" height="700" frameborder="0" style="border:0"></iframe>`;
const popup = `<script src="${origin}/embed.js"></script>\n<button data-form="${slug}">Open form</button>`;
function EmbedPanel({ slug, publicFormsOrigin }: { slug: string; publicFormsOrigin: string }) {
const iframe = `<iframe src="${publicFormsOrigin}/f/${slug}?embed=1" width="100%" height="700" frameborder="0" style="border:0"></iframe>`;
const popup = `<script src="${publicFormsOrigin}/embed.js"></script>\n<button data-form="${slug}">Open form</button>`;
return (
<div className="card p-5 space-y-3 md:col-span-2">
<div className="panel-label">Embed</div>
+2 -1
View File
@@ -7,6 +7,7 @@ import { canEditForm, canViewResults, parseFields, parseSettings } from "./forms
import { visibleFields } from "./logic";
import { recordAudit } from "./audit";
import { snapshotForm } from "./versions";
import { publicFormUrl } from "./urls";
import type { Field, FormSettings } from "./types";
const PROTOCOL_VERSION = "2024-11-05";
@@ -320,7 +321,7 @@ const TOOLS: Record<string, (ctx: Ctx, args: Record<string, unknown>) => Promise
actorId: ctx.userId, action: "form.created", entityType: "form", entityId: form.id,
metadata: { title, via: "mcp" },
});
return JSON.stringify({ id: form.id, slug: form.slug, url: `/f/${form.slug}` }, null, 2);
return JSON.stringify({ id: form.id, slug: form.slug, url: publicFormUrl(form.slug) }, null, 2);
},
async list_responses(ctx, args) {
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { publicFormUrl, publicFormsOrigin } from "./urls";
describe("public form URLs", () => {
test("uses the dedicated public forms origin", () => {
expect(publicFormsOrigin({
PUBLIC_FORM_URL: "https://forms.vyntehome.com/",
AUTH_URL: "https://forms.internal.vyntehome.com",
})).toBe("https://forms.vyntehome.com");
});
test("falls back to the authenticated app origin", () => {
expect(publicFormUrl("customer-feedback", {
AUTH_URL: "https://forms.internal.vyntehome.com/",
})).toBe("https://forms.internal.vyntehome.com/f/customer-feedback");
});
});
+17
View File
@@ -0,0 +1,17 @@
type UrlEnv = {
[key: string]: string | undefined;
PUBLIC_FORM_URL?: string;
AUTH_URL?: string;
};
export function publicFormsOrigin(env: UrlEnv = process.env) {
return trimTrailingSlash(env.PUBLIC_FORM_URL || env.AUTH_URL || "");
}
export function publicFormUrl(slug: string, env: UrlEnv = process.env) {
return `${publicFormsOrigin(env)}/f/${encodeURIComponent(slug)}`;
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, "");
}