From 9cd667f0a895b6de2f659e5d95e106f6901df14c Mon Sep 17 00:00:00 2001 From: "Zachariah K. Sharma" Date: Sun, 7 Jun 2026 20:08:45 -0600 Subject: [PATCH] Support separate public forms domain --- .env.example | 6 ++++-- README.md | 28 ++++++++++++++++++++++++++++ docker-compose.yml | 1 + src/app/app/forms/[id]/page.tsx | 2 ++ src/app/app/page.tsx | 3 ++- src/components/builder/Builder.tsx | 28 +++++++++++++++++----------- src/lib/mcp.ts | 3 ++- src/lib/urls.test.ts | 17 +++++++++++++++++ src/lib/urls.ts | 17 +++++++++++++++++ 9 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 src/lib/urls.test.ts create mode 100644 src/lib/urls.ts diff --git a/.env.example b/.env.example index 07b22d5..f2cea91 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,8 @@ POSTGRES_PASSWORD="change-this" AUTH_SECRET="change-me" # Public origin of the app (no trailing slash). AUTH_URL="https://forms.example.com" +# Public origin used for published form, share, and embed links. +PUBLIC_FORM_URL="https://forms-public.example.com" # --- Authentik OIDC --- # In Authentik: Applications → Providers → Create → OAuth2/OpenID @@ -26,8 +28,8 @@ RATE_LIMIT_DRIVER="memory" # REDIS_URL="redis://localhost:6379" # --- Notifications --- -# Defaults to AUTH_URL in docker-compose.yml. -PUBLIC_BASE_URL="https://forms.example.com" +# Admin origin used for response links in notifications. Defaults to AUTH_URL. +# PUBLIC_BASE_URL="https://forms.example.com" # Email driver: resend | smtp | none EMAIL_DRIVER="none" EMAIL_FROM="Forms " diff --git a/README.md b/README.md index 6c9769e..e2d2c78 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ POSTGRES_PASSWORD=replace-with-a-strong-password AUTH_SECRET=replace-with-openssl-rand-base64-32 AUTH_URL=https://forms.example.com +PUBLIC_FORM_URL=https://forms-public.example.com OIDC_ISSUER=https://authentik.example.com/application/o/formbuilder/ OIDC_CLIENT_ID=replace-with-authentik-client-id @@ -62,6 +63,33 @@ location / { `502 Bad Gateway` means openresty cannot reach the upstream. First verify the app from the proxy host with `curl http://127.0.0.1:3080/signin` or `curl http://:3080/signin`. +### Separate Builder And Public Forms Domains + +To build/manage forms at `forms.internal.vyntehome.com` and serve published forms at `forms.vyntehome.com`, point both reverse-proxy hosts to the same app upstream and set: + +```bash +AUTH_URL=https://forms.internal.vyntehome.com +PUBLIC_FORM_URL=https://forms.vyntehome.com +``` + +Keep the Authentik redirect URI on the internal builder domain: + +```text +https://forms.internal.vyntehome.com/api/auth/callback/oidc +``` + +The public proxy host must forward these paths to the app: + +```text +/f/* +/embed.js +/_next/* +/api/forms/* +/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. + ## Authentik Setup Create an OAuth2/OpenID provider in Authentik: diff --git a/docker-compose.yml b/docker-compose.yml index 198634f..62a9c9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET} AUTH_URL: ${AUTH_URL:?set AUTH_URL} PUBLIC_BASE_URL: ${AUTH_URL} + PUBLIC_FORM_URL: ${PUBLIC_FORM_URL:?set PUBLIC_FORM_URL} OIDC_ISSUER: ${OIDC_ISSUER:?set OIDC_ISSUER} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?set OIDC_CLIENT_ID} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET} diff --git a/src/app/app/forms/[id]/page.tsx b/src/app/app/forms/[id]/page.tsx index 4837833..e7f7038 100644 --- a/src/app/app/forms/[id]/page.tsx +++ b/src/app/app/forms/[id]/page.tsx @@ -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()} /> ); diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 63600ee..59970ee 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -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 ( diff --git a/src/components/builder/Builder.tsx b/src/components/builder/Builder.tsx index ad7ea4b..93ed1d1 100644 --- a/src/components/builder/Builder.tsx +++ b/src/components/builder/Builder.tsx @@ -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(initial); const [selectedId, setSelectedId] = useState(initial.fields[0]?.id ?? null); @@ -225,6 +225,7 @@ export default function Builder({ form: initial, members, currentUserId }: { {/* ── Top bar ── */} 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 })} /> @@ -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 {form.published && ( - @@ -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(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 }: { - + ); } -function EmbedPanel({ slug }: { slug: string }) { - const origin = typeof window !== "undefined" ? window.location.origin : ""; - const iframe = ``; - const popup = `\n`; +function EmbedPanel({ slug, publicFormsOrigin }: { slug: string; publicFormsOrigin: string }) { + const iframe = ``; + const popup = `\n`; return (
Embed
diff --git a/src/lib/mcp.ts b/src/lib/mcp.ts index 3242478..ab61780 100644 --- a/src/lib/mcp.ts +++ b/src/lib/mcp.ts @@ -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) => 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) { diff --git a/src/lib/urls.test.ts b/src/lib/urls.test.ts new file mode 100644 index 0000000..dab4ad2 --- /dev/null +++ b/src/lib/urls.test.ts @@ -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"); + }); +}); diff --git a/src/lib/urls.ts b/src/lib/urls.ts new file mode 100644 index 0000000..69cd935 --- /dev/null +++ b/src/lib/urls.ts @@ -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(/\/+$/, ""); +}