diff --git a/.env.example b/.env.example
index fbe29f4356..72dd26c7f1 100644
--- a/.env.example
+++ b/.env.example
@@ -184,8 +184,13 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
-# OpenTelemetry URL for tracing
-# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
+# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
+# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
+# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
+# OTEL_SERVICE_NAME=formbricks
+# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
+# OTEL_TRACES_SAMPLER=parentbased_traceidratio
+# OTEL_TRACES_SAMPLER_ARG=1
# Unsplash API Key
UNSPLASH_ACCESS_KEY=
diff --git a/.github/workflows/release-helm-chart.yml b/.github/workflows/release-helm-chart.yml
index eca5a3d714..9464921020 100644
--- a/.github/workflows/release-helm-chart.yml
+++ b/.github/workflows/release-helm-chart.yml
@@ -65,8 +65,8 @@ jobs:
set -euo pipefail
echo "Updating Chart.yaml with version: ${VERSION}"
- yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
- yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
+ yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
+ yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
echo "✅ Successfully updated Chart.yaml"
@@ -77,7 +77,7 @@ jobs:
set -euo pipefail
echo "Packaging Helm chart version: ${VERSION}"
- helm package ./helm-chart
+ helm package ./charts/formbricks
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml
index 4c5ba986f3..9f0a1fc2fa 100644
--- a/.github/workflows/sonarqube.yml
+++ b/.github/workflows/sonarqube.yml
@@ -9,6 +9,7 @@ on:
merge_group:
permissions:
contents: read
+ pull-requests: read
jobs:
sonarqube:
name: SonarQube
@@ -50,6 +51,9 @@ jobs:
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
+ with:
+ args: >
+ -Dsonar.verbose=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml
index c901eb5832..30e90872ec 100644
--- a/.github/workflows/translation-check.yml
+++ b/.github/workflows/translation-check.yml
@@ -32,21 +32,20 @@ jobs:
with:
egress-policy: audit
- - name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
-
- - name: Setup Node.js
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
- node-version: 18
+ fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- - name: Setup pnpm
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
+ - name: Setup Node.js 22.x
+ uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
- version: 9.15.9
+ node-version: 22.x
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
- run: pnpm install --frozen-lockfile
+ run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
run: |
diff --git a/.gitignore b/.gitignore
index c5a11230c6..19b187e231 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
**/.next/
**/out/
**/build
+**/next-env.d.ts
# node
**/dist/
@@ -63,3 +64,5 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
.cursorrules
i18n.cache
stats.html
+# next-agents-md
+.next-docs/
diff --git a/AGENTS.md b/AGENTS.md
index d84dc5c6a5..cd74ba8c4a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,6 +15,19 @@ Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surf
- `pnpm test:e2e` — launch the Playwright browser regression suite.
- `pnpm db:migrate:dev` — apply Prisma migrations against the dev database.
+### Survey Packages Build & Cache
+
+The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the built bundle is copied to `apps/web/public/js/`. The Next.js app imports from `dist/`, **not** the source files. This means:
+
+- After any change to `packages/surveys` or its dependencies (`packages/survey-ui`, `packages/types`, etc.), you **must rebuild** for changes to take effect in the running app.
+- Turborepo caches build outputs aggressively. Always use `--force` to bypass the cache when iterating on survey packages:
+ ```
+ rm -rf packages/surveys/dist apps/web/public/js/surveys.* node_modules/.cache/turbo
+ pnpm build --filter=@formbricks/surveys... --force
+ ```
+- The browser also caches the UMD bundle (`surveys.umd.cjs`) served from `public/js/`. After rebuilding, do a **hard refresh** (Cmd+Shift+R / Ctrl+Shift+R) or disable the browser cache via DevTools to pick up the new bundle.
+- If changes still don't appear, restart the Next.js dev server (`pnpm dev`).
+
## Coding Style & Naming Conventions
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
@@ -80,3 +93,5 @@ Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs coloc
## Commit & Pull Request Guidelines
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
+
+[Next.js Docs Index]|root: ./.next-docs|STOP. What you remember about Next.js is WRONG for this project. Always search docs and read before any task.|If docs missing, run this command first: npx @next/codemod agents-md --output AGENTS.md|01-app:{04-glossary.mdx}|01-app/01-getting-started:{01-installation.mdx,02-project-structure.mdx,03-layouts-and-pages.mdx,04-linking-and-navigating.mdx,05-server-and-client-components.mdx,06-cache-components.mdx,07-fetching-data.mdx,08-updating-data.mdx,09-caching-and-revalidating.mdx,10-error-handling.mdx,11-css.mdx,12-images.mdx,13-fonts.mdx,14-metadata-and-og-images.mdx,15-route-handlers.mdx,16-proxy.mdx,17-deploying.mdx,18-upgrading.mdx}|01-app/02-guides:{analytics.mdx,authentication.mdx,backend-for-frontend.mdx,caching.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,data-security.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instrumentation.mdx,internationalization.mdx,json-ld.mdx,lazy-loading.mdx,local-development.mdx,mcp.mdx,mdx.mdx,memory-usage.mdx,multi-tenant.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,prefetching.mdx,production-checklist.mdx,progressive-web-apps.mdx,public-static-pages.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,single-page-applications.mdx,static-exports.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx,videos.mdx}|01-app/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|01-app/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|01-app/02-guides/upgrading:{codemods.mdx,version-14.mdx,version-15.mdx,version-16.mdx}|01-app/03-api-reference:{07-edge.mdx,08-turbopack.mdx}|01-app/03-api-reference/01-directives:{use-cache-private.mdx,use-cache-remote.mdx,use-cache.mdx,use-client.mdx,use-server.mdx}|01-app/03-api-reference/02-components:{font.mdx,form.mdx,image.mdx,link.mdx,script.mdx}|01-app/03-api-reference/03-file-conventions/01-metadata:{app-icons.mdx,manifest.mdx,opengraph-image.mdx,robots.mdx,sitemap.mdx}|01-app/03-api-reference/03-file-conventions:{default.mdx,dynamic-routes.mdx,error.mdx,forbidden.mdx,instrumentation-client.mdx,instrumentation.mdx,intercepting-routes.mdx,layout.mdx,loading.mdx,mdx-components.mdx,not-found.mdx,page.mdx,parallel-routes.mdx,proxy.mdx,public-folder.mdx,route-groups.mdx,route-segment-config.mdx,route.mdx,src-folder.mdx,template.mdx,unauthorized.mdx}|01-app/03-api-reference/04-functions:{after.mdx,cacheLife.mdx,cacheTag.mdx,connection.mdx,cookies.mdx,draft-mode.mdx,fetch.mdx,forbidden.mdx,generate-image-metadata.mdx,generate-metadata.mdx,generate-sitemaps.mdx,generate-static-params.mdx,generate-viewport.mdx,headers.mdx,image-response.mdx,next-request.mdx,next-response.mdx,not-found.mdx,permanentRedirect.mdx,redirect.mdx,refresh.mdx,revalidatePath.mdx,revalidateTag.mdx,unauthorized.mdx,unstable_cache.mdx,unstable_noStore.mdx,unstable_rethrow.mdx,updateTag.mdx,use-link-status.mdx,use-params.mdx,use-pathname.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,use-selected-layout-segment.mdx,use-selected-layout-segments.mdx,userAgent.mdx}|01-app/03-api-reference/05-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,appDir.mdx,assetPrefix.mdx,authInterrupts.mdx,basePath.mdx,browserDebugInfoInTerminal.mdx,cacheComponents.mdx,cacheHandlers.mdx,cacheLife.mdx,compress.mdx,crossOrigin.mdx,cssChunking.mdx,devIndicators.mdx,distDir.mdx,env.mdx,expireTime.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,htmlLimitedBots.mdx,httpAgentOptions.mdx,images.mdx,incrementalCacheHandlerPath.mdx,inlineCss.mdx,isolatedDevBuild.mdx,logging.mdx,mdxRs.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactCompiler.mdx,reactMaxHeadersLength.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,sassOptions.mdx,serverActions.mdx,serverComponentsHmrCache.mdx,serverExternalPackages.mdx,staleTimes.mdx,staticGeneration.mdx,taint.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,turbopackFileSystemCache.mdx,typedRoutes.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,viewTransition.mdx,webVitalsAttribution.mdx,webpack.mdx}|01-app/03-api-reference/05-config:{02-typescript.mdx,03-eslint.mdx}|01-app/03-api-reference/06-cli:{create-next-app.mdx,next.mdx}|02-pages/01-getting-started:{01-installation.mdx,02-project-structure.mdx,04-images.mdx,05-fonts.mdx,06-css.mdx,11-deploying.mdx}|02-pages/02-guides:{analytics.mdx,authentication.mdx,babel.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instrumentation.mdx,internationalization.mdx,lazy-loading.mdx,mdx.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,post-css.mdx,preview-mode.mdx,production-checklist.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,static-exports.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx}|02-pages/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|02-pages/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|02-pages/02-guides/upgrading:{codemods.mdx,version-10.mdx,version-11.mdx,version-12.mdx,version-13.mdx,version-14.mdx,version-9.mdx}|02-pages/03-building-your-application/01-routing:{01-pages-and-layouts.mdx,02-dynamic-routes.mdx,03-linking-and-navigating.mdx,05-custom-app.mdx,06-custom-document.mdx,07-api-routes.mdx,08-custom-error.mdx}|02-pages/03-building-your-application/02-rendering:{01-server-side-rendering.mdx,02-static-site-generation.mdx,04-automatic-static-optimization.mdx,05-client-side-rendering.mdx}|02-pages/03-building-your-application/03-data-fetching:{01-get-static-props.mdx,02-get-static-paths.mdx,03-forms-and-mutations.mdx,03-get-server-side-props.mdx,05-client-side.mdx}|02-pages/03-building-your-application/06-configuring:{12-error-handling.mdx}|02-pages/04-api-reference:{06-edge.mdx,08-turbopack.mdx}|02-pages/04-api-reference/01-components:{font.mdx,form.mdx,head.mdx,image-legacy.mdx,image.mdx,link.mdx,script.mdx}|02-pages/04-api-reference/02-file-conventions:{instrumentation.mdx,proxy.mdx,public-folder.mdx,src-folder.mdx}|02-pages/04-api-reference/03-functions:{get-initial-props.mdx,get-server-side-props.mdx,get-static-paths.mdx,get-static-props.mdx,next-request.mdx,next-response.mdx,use-params.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,userAgent.mdx}|02-pages/04-api-reference/04-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,assetPrefix.mdx,basePath.mdx,bundlePagesRouterDependencies.mdx,compress.mdx,crossOrigin.mdx,devIndicators.mdx,distDir.mdx,env.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,httpAgentOptions.mdx,images.mdx,isolatedDevBuild.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,serverExternalPackages.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,webVitalsAttribution.mdx,webpack.mdx}|02-pages/04-api-reference/04-config:{01-typescript.mdx,02-eslint.mdx}|02-pages/04-api-reference/05-cli:{create-next-app.mdx,next.mdx}|03-architecture:{accessibility.mdx,fast-refresh.mdx,nextjs-compiler.mdx,supported-browsers.mdx}|04-community:{01-contribution-guide.mdx,02-rspack.mdx}
diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js
index 8b5dc0e00e..a114584fc8 100644
--- a/apps/web/.eslintrc.js
+++ b/apps/web/.eslintrc.js
@@ -1,20 +1,4 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-next.js"],
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
- overrides: [
- {
- files: ["locales/*.json"],
- plugins: ["i18n-json"],
- rules: {
- "i18n-json/identical-keys": [
- "error",
- {
- filePath: require("path").join(__dirname, "locales", "en-US.json"),
- checkExtraKeys: false,
- checkMissingKeys: true,
- },
- ],
- },
- },
- ],
};
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index b9381e45e3..a54c504043 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:22-alpine3.22 AS base
+FROM node:24-alpine3.23 AS base
#
## step 1: Prune monorepo
@@ -20,7 +20,7 @@ FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
-RUN corepack prepare pnpm@9.15.9 --activate
+RUN corepack prepare pnpm@10.28.2 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -69,20 +69,14 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=sentry_auth_token \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
-# Extract Prisma version
-RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
-
#
## step 3: setup production runner
#
FROM base AS runner
-RUN npm install --ignore-scripts -g corepack@latest && \
- corepack enable
-
-RUN apk add --no-cache curl \
- && apk add --no-cache supercronic \
- # && addgroup --system --gid 1001 nodejs \
+# Update npm to latest, then create user
+# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
+RUN npm install --ignore-scripts -g npm@latest \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -113,15 +107,13 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
+# Copy prisma client packages
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
-COPY --from=installer /prisma_version.txt .
-RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
-
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
@@ -134,7 +126,25 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
-RUN npm install -g prisma@6
+# Pino loads transport code in worker threads via dynamic require().
+# Next.js file tracing only traces static imports, missing runtime-loaded files
+# (e.g. pino/lib/transport-stream.js, transport targets).
+# Copy the full packages to ensure all runtime files are available.
+COPY --from=installer /app/node_modules/pino ./node_modules/pino
+RUN chmod -R 755 ./node_modules/pino
+
+COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
+RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
+
+COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
+RUN chmod -R 755 ./node_modules/pino-abstract-transport
+
+COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
+RUN chmod -R 755 ./node_modules/otlp-logger
+
+# Install prisma CLI globally for database migrations and fix permissions for nextjs user
+RUN npm install --ignore-scripts -g prisma@6 \
+ && chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -144,10 +154,8 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
-# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
-RUN corepack prepare pnpm@9.15.9 --activate && \
- mkdir -p /home/nextjs/apps/web/uploads/ && \
+RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.test.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.test.ts
index 216b19427e..0201e02a60 100644
--- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.test.ts
+++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.test.ts
@@ -25,7 +25,7 @@ const mockProject: TProject = {
},
placement: "bottomRight",
clickOutsideClose: true,
- darkOverlay: false,
+ overlay: "none",
environments: [],
languages: [],
logo: null,
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings.tsx
index 69c15869ef..ac94995ece 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings.tsx
@@ -3,7 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import { useRouter } from "next/navigation";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ import {
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
+import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -64,10 +65,17 @@ export const ProjectSettings = ({
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
try {
+ // Build the full styling from the chosen brand color so all derived
+ // colours (question, button, input, option, progress, etc.) are persisted.
+ // Without this, only brandColor is saved and the look-and-feel page falls
+ // back to STYLE_DEFAULTS computed from the default brand (#64748b).
+ const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
+
const createProjectResponse = await createProjectAction({
organizationId,
data: {
...data,
+ styling: fullStyling,
config: { channel, industry },
teamIds: data.teamIds,
},
@@ -112,6 +120,7 @@ export const ProjectSettings = ({
const projectName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
+ const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
const { isSubmitting } = form.formState;
const organizationTeamsOptions = organizationTeams.map((team) => ({
@@ -226,7 +235,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
- className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
+ className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
{t("common.preview")}
@@ -235,7 +244,7 @@ export const ProjectSettings = ({
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
- styling={{ brandColor: { light: brandColor } }}
+ styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts
index 618c2512e7..59459dada8 100644
--- a/apps/web/app/(app)/environments/[environmentId]/actions.ts
+++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
-import { OperationNotAllowedError } from "@formbricks/types/errors";
+import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -138,7 +138,7 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
// Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
- throw new Error("Membership not found");
+ throw new AuthorizationError("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
index 546bfef45f..cd136f31b7 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx
@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Calculate derived values (no queries)
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
- const { features, lastChecked, isPendingDowngrade, active } = license;
+ const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager;
@@ -63,6 +63,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
active={active}
environmentId={environment.id}
locale={user.locale}
+ status={status}
/>
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
index d663e84896..7a2618ee8a 100644
--- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx
@@ -110,7 +110,10 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/contacts`,
name: "Distribute",
icon: UserIcon,
- isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
+ isActive:
+ pathname?.includes("/contacts") ||
+ pathname?.includes("/segments") ||
+ pathname?.includes("/attributes"),
},
{
name: "Unify",
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus.tsx
new file mode 100644
index 0000000000..8aa0355817
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { TFunction } from "i18next";
+import { RotateCcwIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
+import { Alert, AlertDescription } from "@/modules/ui/components/alert";
+import { Badge } from "@/modules/ui/components/badge";
+import { Button } from "@/modules/ui/components/button";
+import { SettingsCard } from "../../../components/SettingsCard";
+
+type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
+
+interface EnterpriseLicenseStatusProps {
+ status: LicenseStatus;
+ gracePeriodEnd?: Date;
+ environmentId: string;
+}
+
+const getBadgeConfig = (
+ status: LicenseStatus,
+ t: TFunction
+): { type: "success" | "error" | "warning" | "gray"; label: string } => {
+ switch (status) {
+ case "active":
+ return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
+ case "expired":
+ return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
+ case "unreachable":
+ return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
+ case "invalid_license":
+ return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
+ default:
+ return { type: "gray", label: t("environments.settings.enterprise.license_status") };
+ }
+};
+
+export const EnterpriseLicenseStatus = ({
+ status,
+ gracePeriodEnd,
+ environmentId,
+}: EnterpriseLicenseStatusProps) => {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const [isRechecking, setIsRechecking] = useState(false);
+
+ const handleRecheck = async () => {
+ setIsRechecking(true);
+ try {
+ const result = await recheckLicenseAction({ environmentId });
+ if (result?.serverError) {
+ toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
+ return;
+ }
+
+ if (result?.data) {
+ if (result.data.status === "unreachable") {
+ toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
+ } else if (result.data.status === "invalid_license") {
+ toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
+ } else {
+ toast.success(t("environments.settings.enterprise.recheck_license_success"));
+ }
+ router.refresh();
+ } else {
+ toast.error(t("environments.settings.enterprise.recheck_license_failed"));
+ }
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
+ );
+ } finally {
+ setIsRechecking(false);
+ }
+ };
+
+ const badgeConfig = getBadgeConfig(status, t);
+
+ return (
+
+
+
+
+
+
+
+
+ {status === "unreachable" && gracePeriodEnd && (
+
+
+ {t("environments.settings.enterprise.license_unreachable_grace_period", {
+ gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ }),
+ })}
+
+
+ )}
+ {status === "invalid_license" && (
+
+
+ {t("environments.settings.enterprise.license_invalid_description")}
+
+
+ )}
+
+ {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
+
+ hola@formbricks.com
+
+
+
+
+ );
+};
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx
index a515bcd75f..9252c58e3b 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx
@@ -2,9 +2,10 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
+import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
-import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
+import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -25,7 +26,8 @@ const Page = async (props) => {
return notFound();
}
- const { active: isEnterpriseEdition } = await getEnterpriseLicense();
+ const licenseState = await getEnterpriseLicense();
+ const hasLicense = licenseState.status !== "no-license";
const paidFeatures = [
{
@@ -90,35 +92,22 @@ const Page = async (props) => {
activeId="enterprise"
/>
- {isEnterpriseEdition ? (
-
-
-
-
-
-
-
-
- {t(
- "environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked"
- )}
-
-
-
- {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
-
- hola@formbricks.com
-
-
-
-
-
+ {hasLicense ? (
+
) : (