Support separate public forms domain
This commit is contained in:
+4
-2
@@ -7,6 +7,8 @@ POSTGRES_PASSWORD="change-this"
|
|||||||
AUTH_SECRET="change-me"
|
AUTH_SECRET="change-me"
|
||||||
# Public origin of the app (no trailing slash).
|
# Public origin of the app (no trailing slash).
|
||||||
AUTH_URL="https://forms.example.com"
|
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 ---
|
# --- Authentik OIDC ---
|
||||||
# In Authentik: Applications → Providers → Create → OAuth2/OpenID
|
# In Authentik: Applications → Providers → Create → OAuth2/OpenID
|
||||||
@@ -26,8 +28,8 @@ RATE_LIMIT_DRIVER="memory"
|
|||||||
# REDIS_URL="redis://localhost:6379"
|
# REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
# --- Notifications ---
|
# --- Notifications ---
|
||||||
# Defaults to AUTH_URL in docker-compose.yml.
|
# Admin origin used for response links in notifications. Defaults to AUTH_URL.
|
||||||
PUBLIC_BASE_URL="https://forms.example.com"
|
# PUBLIC_BASE_URL="https://forms.example.com"
|
||||||
# Email driver: resend | smtp | none
|
# Email driver: resend | smtp | none
|
||||||
EMAIL_DRIVER="none"
|
EMAIL_DRIVER="none"
|
||||||
EMAIL_FROM="Forms <forms@example.com>"
|
EMAIL_FROM="Forms <forms@example.com>"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ POSTGRES_PASSWORD=replace-with-a-strong-password
|
|||||||
|
|
||||||
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||||
AUTH_URL=https://forms.example.com
|
AUTH_URL=https://forms.example.com
|
||||||
|
PUBLIC_FORM_URL=https://forms-public.example.com
|
||||||
|
|
||||||
OIDC_ISSUER=https://authentik.example.com/application/o/formbuilder/
|
OIDC_ISSUER=https://authentik.example.com/application/o/formbuilder/
|
||||||
OIDC_CLIENT_ID=replace-with-authentik-client-id
|
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://<docker-host-ip>:3080/signin`.
|
`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://<docker-host-ip>: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
|
## Authentik Setup
|
||||||
|
|
||||||
Create an OAuth2/OpenID provider in Authentik:
|
Create an OAuth2/OpenID provider in Authentik:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET}
|
AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET}
|
||||||
AUTH_URL: ${AUTH_URL:?set AUTH_URL}
|
AUTH_URL: ${AUTH_URL:?set AUTH_URL}
|
||||||
PUBLIC_BASE_URL: ${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_ISSUER: ${OIDC_ISSUER:?set OIDC_ISSUER}
|
||||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?set OIDC_CLIENT_ID}
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:?set OIDC_CLIENT_ID}
|
||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:?set OIDC_CLIENT_SECRET}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { canEditForm, parseFields, parseSettings } from "@/lib/forms";
|
|||||||
import { listAllTags } from "@/lib/tags";
|
import { listAllTags } from "@/lib/tags";
|
||||||
import Builder from "@/components/builder/Builder";
|
import Builder from "@/components/builder/Builder";
|
||||||
import TagBar from "@/components/ui/TagBar";
|
import TagBar from "@/components/ui/TagBar";
|
||||||
|
import { publicFormsOrigin } from "@/lib/urls";
|
||||||
|
|
||||||
export default async function FormPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function FormPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -48,6 +49,7 @@ export default async function FormPage({ params }: { params: Promise<{ id: strin
|
|||||||
}}
|
}}
|
||||||
members={members}
|
members={members}
|
||||||
currentUserId={session.user.id}
|
currentUserId={session.user.id}
|
||||||
|
publicFormsOrigin={publicFormsOrigin()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/db";
|
|||||||
import { createForm, createFromTemplate } from "@/lib/actions";
|
import { createForm, createFromTemplate } from "@/lib/actions";
|
||||||
import { TEMPLATES } from "@/lib/templates";
|
import { TEMPLATES } from "@/lib/templates";
|
||||||
import { listAllTags } from "@/lib/tags";
|
import { listAllTags } from "@/lib/tags";
|
||||||
|
import { publicFormUrl } from "@/lib/urls";
|
||||||
import { Plus, FileText, Layers } from "lucide-react";
|
import { Plus, FileText, Layers } from "lucide-react";
|
||||||
|
|
||||||
export default async function FormsList({ searchParams }: { searchParams: Promise<{ tag?: string }> }) {
|
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) => {
|
{forms.map((f) => {
|
||||||
const canEdit = isAdmin || f.ownerId === userId;
|
const canEdit = isAdmin || f.ownerId === userId;
|
||||||
const canSeeCount = canEdit || f.viewers.length > 0;
|
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;
|
const ownerLabel = f.owner.name || f.owner.email;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ function fieldChipClass(type: FieldType): string {
|
|||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function Builder({ form: initial, members, currentUserId }: {
|
export default function Builder({ form: initial, members, currentUserId, publicFormsOrigin }: {
|
||||||
form: FormData; members: Member[]; currentUserId: string;
|
form: FormData; members: Member[]; currentUserId: string; publicFormsOrigin: string;
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState<FormData>(initial);
|
const [form, setForm] = useState<FormData>(initial);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(initial.fields[0]?.id ?? null);
|
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 ── */}
|
{/* ── Top bar ── */}
|
||||||
<TopBar
|
<TopBar
|
||||||
form={form} saving={saving} savedAt={savedAt} tab={tab} setTab={setTab}
|
form={form} saving={saving} savedAt={savedAt} tab={tab} setTab={setTab}
|
||||||
|
publicFormUrl={`${publicFormsOrigin}/f/${encodeURIComponent(form.slug)}`}
|
||||||
onTitleChange={(t) => update({ title: t })}
|
onTitleChange={(t) => update({ title: t })}
|
||||||
onTogglePublish={togglePublish}
|
onTogglePublish={togglePublish}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
@@ -401,6 +402,7 @@ export default function Builder({ form: initial, members, currentUserId }: {
|
|||||||
form={form}
|
form={form}
|
||||||
members={members}
|
members={members}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
publicFormsOrigin={publicFormsOrigin}
|
||||||
onSettings={(s) => update({ settings: s })}
|
onSettings={(s) => update({ settings: s })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,8 +413,9 @@ export default function Builder({ form: initial, members, currentUserId }: {
|
|||||||
|
|
||||||
// ── Top bar ────────────────────────────────────────────────────────────────
|
// ── 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;
|
form: FormData; saving: boolean; savedAt: number | null; tab: Tab; setTab: (t: Tab) => void;
|
||||||
|
publicFormUrl: string;
|
||||||
onTitleChange: (t: string) => void; onTogglePublish: () => void; onDelete: () => void;
|
onTitleChange: (t: string) => void; onTogglePublish: () => void; onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const statusText = saving ? "Saving…" : savedAt ? "Saved" : null;
|
const statusText = saving ? "Saving…" : savedAt ? "Saved" : null;
|
||||||
@@ -466,7 +469,7 @@ function TopBar({ form, saving, savedAt, tab, setTab, onTitleChange, onTogglePub
|
|||||||
<BarChart2 size={13} />
|
<BarChart2 size={13} />
|
||||||
</Link>
|
</Link>
|
||||||
{form.published && (
|
{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))]">
|
className="btn btn-ghost btn-sm btn-icon text-[rgb(var(--muted))]">
|
||||||
<ExternalLink size={13} />
|
<ExternalLink size={13} />
|
||||||
</a>
|
</a>
|
||||||
@@ -1168,14 +1171,18 @@ function SettingsConfig({ settings, description, formId, onChange }: {
|
|||||||
|
|
||||||
// ── Share tab ──────────────────────────────────────────────────────────────
|
// ── Share tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ShareTab({ form, members, currentUserId, onSettings }: {
|
function ShareTab({ form, members, currentUserId, publicFormsOrigin, onSettings }: {
|
||||||
form: FormData; members: Member[]; currentUserId: string;
|
form: FormData; members: Member[]; currentUserId: string;
|
||||||
|
publicFormsOrigin: string;
|
||||||
onSettings: (s: FormSettings) => void;
|
onSettings: (s: FormSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
const [vis, setVis] = useState(form.settings.visibility ?? "workspace");
|
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(() => `${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") => {
|
const changeVisibility = (v: "workspace" | "public") => {
|
||||||
setVis(v);
|
setVis(v);
|
||||||
@@ -1231,15 +1238,14 @@ function ShareTab({ form, members, currentUserId, onSettings }: {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmbedPanel slug={form.slug} />
|
<EmbedPanel slug={form.slug} publicFormsOrigin={publicFormsOrigin} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmbedPanel({ slug }: { slug: string }) {
|
function EmbedPanel({ slug, publicFormsOrigin }: { slug: string; publicFormsOrigin: string }) {
|
||||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
const iframe = `<iframe src="${publicFormsOrigin}/f/${slug}?embed=1" width="100%" height="700" frameborder="0" style="border:0"></iframe>`;
|
||||||
const iframe = `<iframe src="${origin}/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>`;
|
||||||
const popup = `<script src="${origin}/embed.js"></script>\n<button data-form="${slug}">Open form</button>`;
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-5 space-y-3 md:col-span-2">
|
<div className="card p-5 space-y-3 md:col-span-2">
|
||||||
<div className="panel-label">Embed</div>
|
<div className="panel-label">Embed</div>
|
||||||
|
|||||||
+2
-1
@@ -7,6 +7,7 @@ import { canEditForm, canViewResults, parseFields, parseSettings } from "./forms
|
|||||||
import { visibleFields } from "./logic";
|
import { visibleFields } from "./logic";
|
||||||
import { recordAudit } from "./audit";
|
import { recordAudit } from "./audit";
|
||||||
import { snapshotForm } from "./versions";
|
import { snapshotForm } from "./versions";
|
||||||
|
import { publicFormUrl } from "./urls";
|
||||||
import type { Field, FormSettings } from "./types";
|
import type { Field, FormSettings } from "./types";
|
||||||
|
|
||||||
const PROTOCOL_VERSION = "2024-11-05";
|
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,
|
actorId: ctx.userId, action: "form.created", entityType: "form", entityId: form.id,
|
||||||
metadata: { title, via: "mcp" },
|
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) {
|
async list_responses(ctx, args) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(/\/+$/, "");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user