feat: Add base path support for Formbricks (#6853)

This commit is contained in:
Matti Nannt
2025-12-17 18:13:32 +01:00
committed by GitHub
parent 07ed926225
commit e71f3f412c
21 changed files with 76 additions and 10 deletions

View File

@@ -9,8 +9,12 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
# BASE_PATH=
# Encryption keys
# Please set both for now, we will change this in the future

View File

@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
# Base path for the application (optional)
ARG BASE_PATH=""
ENV BASE_PATH=${BASE_PATH}
# Set the working directory
WORKDIR /app

View File

@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
@@ -55,6 +56,7 @@ export const ProjectSettings = ({
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
publicDomain,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -231,6 +233,7 @@ export const ProjectSettings = ({
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}

View File

@@ -5,6 +5,7 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -47,6 +48,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -62,6 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
<Button

View File

@@ -1,6 +1,7 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
@@ -15,6 +16,7 @@ interface EnvironmentLayoutProps {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
@@ -72,6 +74,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar

View File

@@ -46,6 +46,7 @@ interface NavigationProps {
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
@@ -56,6 +57,7 @@ export const MainNavigation = ({
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -286,15 +288,16 @@ export const MainNavigation = ({
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
callbackUrl: loginUrl,
clearEnvironmentId: true,
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -44,6 +44,7 @@ export const env = createEnv({
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -168,6 +169,7 @@ export const env = createEnv({
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,

View File

@@ -3,15 +3,15 @@
import { signIn } from "next-auth/react";
import { useEffect } from "react";
export const SignIn = ({ token }) => {
export const SignIn = ({ token, webAppUrl }) => {
useEffect(() => {
if (token) {
signIn("token", {
token: token,
callbackUrl: `/`,
callbackUrl: webAppUrl,
});
}
}, [token]);
}, [token, webAppUrl]);
return <></>;
};

View File

@@ -1,3 +1,4 @@
import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { SignIn } from "@/modules/auth/verify/components/sign-in";
@@ -9,7 +10,7 @@ export const VerifyPage = async ({ searchParams }) => {
return token ? (
<FormWrapper>
<p className="text-center">{t("auth.verify.verifying")}</p>
<SignIn token={token} />
<SignIn token={token} webAppUrl={WEBAPP_URL} />
</FormWrapper>
) : (
<p className="text-center">{t("auth.verify.no_token_provided")}</p>

View File

@@ -38,6 +38,7 @@ interface ThemeStylingProps {
isUnsplashConfigured: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
publicDomain: string;
}
export const ThemeStyling = ({
@@ -47,6 +48,7 @@ export const ThemeStyling = ({
isUnsplashConfigured,
isReadOnly,
isStorageConfigured = true,
publicDomain,
}: ThemeStylingProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -199,6 +201,7 @@ export const ThemeStyling = ({
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
publicDomain={publicDomain}
/>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
@@ -27,6 +28,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
@@ -49,6 +51,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
publicDomain={publicDomain}
/>
</SettingsCard>
<SettingsCard

View File

@@ -50,6 +50,7 @@ interface SurveyEditorProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
publicDomain: string;
}
export const SurveyEditor = ({
@@ -79,6 +80,7 @@ export const SurveyEditor = ({
isStorageConfigured,
quotas,
isExternalUrlsAllowed,
publicDomain,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
const [activeElementId, setActiveElementId] = useState<string | null>(null);
@@ -272,6 +274,7 @@ export const SurveyEditor = ({
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
isSpamProtectionAllowed={isSpamProtectionAllowed}
publicDomain={publicDomain}
/>
</aside>
</div>

View File

@@ -6,6 +6,7 @@ import {
SURVEY_BG_COLORS,
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -105,6 +106,7 @@ export const SurveyEditorPage = async (props) => {
}
const isCxMode = searchParams.mode === "cx";
const publicDomain = getPublicDomain();
return (
<SurveyEditor
@@ -134,6 +136,7 @@ export const SurveyEditorPage = async (props) => {
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
/>
);
};

View File

@@ -70,6 +70,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
environment={environment}
project={projectWithRequiredProps}
isTemplatePage={false}
publicDomain={publicDomain}
/>
);

View File

@@ -16,6 +16,7 @@ type TemplateContainerWithPreviewProps = {
environment: Pick<Environment, "id" | "appSetupCompleted">;
userId: string;
isTemplatePage?: boolean;
publicDomain: string;
};
export const TemplateContainerWithPreview = ({
@@ -23,6 +24,7 @@ export const TemplateContainerWithPreview = ({
environment,
userId,
isTemplatePage = true,
publicDomain,
}: TemplateContainerWithPreviewProps) => {
const { t } = useTranslation();
const initialTemplate = customSurveyTemplate(t);
@@ -72,6 +74,7 @@ export const TemplateContainerWithPreview = ({
environment={environment}
languageCode={"default"}
isSpamProtectionAllowed={false} // setting it to false as this is a template
publicDomain={publicDomain}
/>
)}
</aside>

View File

@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
@@ -27,7 +28,14 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
return redirect(`/environments/${environment.id}/surveys`);
}
const publicDomain = getPublicDomain();
return (
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={project}
publicDomain={publicDomain}
/>
);
};

View File

@@ -25,6 +25,7 @@ interface PreviewSurveyProps {
environment: Pick<Environment, "id" | "appSetupCompleted">;
languageCode: string;
isSpamProtectionAllowed: boolean;
publicDomain: string;
}
let surveyNameTemp: string;
@@ -38,6 +39,7 @@ export const PreviewSurvey = ({
environment,
languageCode,
isSpamProtectionAllowed,
publicDomain,
}: PreviewSurveyProps) => {
const [isModalOpen, setIsModalOpen] = useState(true);
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
@@ -244,6 +246,7 @@ export const PreviewSurvey = ({
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -273,6 +276,7 @@ export const PreviewSurvey = ({
</div>
<div className="z-10 w-full rounded-lg border border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}
@@ -345,6 +349,7 @@ export const PreviewSurvey = ({
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -378,6 +383,7 @@ export const PreviewSurvey = ({
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}

View File

@@ -1,3 +1,5 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { executeRecaptcha, loadRecaptchaScript } from "@/modules/ui/components/survey/recaptcha";
@@ -37,7 +39,8 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
// Set loading flag immediately to prevent concurrent loads
isLoadingScript = true;
try {
const response = await fetch("/js/surveys.umd.cjs");
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
const response = await fetch(scriptUrl);
if (!response.ok) {
throw new Error("Failed to load the surveys package");

View File

@@ -16,6 +16,7 @@ interface ThemeStylingPreviewSurveyProps {
project: Project;
previewType: TSurveyType;
setPreviewType: (type: TSurveyType) => void;
publicDomain: string;
}
const previewParentContainerVariant: Variants = {
@@ -50,6 +51,7 @@ export const ThemeStylingPreviewSurvey = ({
project,
previewType,
setPreviewType,
publicDomain,
}: ThemeStylingPreviewSurveyProps) => {
const [isFullScreenPreview] = useState(false);
const [previewPosition] = useState("relative");
@@ -166,6 +168,7 @@ export const ThemeStylingPreviewSurvey = ({
borderRadius={project.styling.roundness ?? 8}>
<Fragment key={surveyKey}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "app" }}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -192,6 +195,7 @@ export const ThemeStylingPreviewSurvey = ({
key={surveyKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}

View File

@@ -16,6 +16,7 @@ const getHostname = (url) => {
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
basePath: process.env.BASE_PATH || undefined,
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: true,
@@ -403,7 +404,7 @@ const nextConfig = {
];
},
env: {
NEXTAUTH_URL: process.env.WEBAPP_URL,
NEXTAUTH_URL: process.env.NEXTAUTH_URL, // TODO: Remove this once we have a proper solution for the base path
},
};
@@ -441,4 +442,7 @@ const sentryOptions = {
// Runtime Sentry reporting still depends on DSN being set via environment variables
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
console.log("BASE PATH", nextConfig.basePath);
export default exportConfig;

View File

@@ -152,6 +152,7 @@
"AZUREAD_TENANT_ID",
"AUTH_SSO_DEFAULT_TEAM_ID",
"AUTH_SKIP_INVITE_FOR_SSO",
"BASE_PATH",
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",