Compare commits

...

26 Commits

Author SHA1 Message Date
Matti Nannt 05d7d1165a fix: strip client-provided timestamps in client response API (ENG-828)
The client-facing POST /api/v1/client and /api/v2/client response
endpoints accepted arbitrary createdAt/updatedAt values, allowing any
respondent to backdate or future-date their submission and poison
time-series analytics.

Strip both fields before the Prisma insert in the client code paths.
The management API retains the ability to pass timestamps, as it is
authenticated and used for legitimate server-side data imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:32:06 +02:00
Dhruwang Jariwala eea7df81b4 fix: seed default contact attribute keys on workspace creation (ENG-929) (#8036)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:34:33 +00:00
Johannes c172e2a33c fix: use block terminology in conditional logic docs (#7942)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 06:57:46 +00:00
Harsh Bhat 9a5780d510 chore: A/B test reduce cog. load in question section (#7944)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-18 06:56:38 +00:00
Johannes e333d8ba02 docs: add custom CSS guide for website & app surveys (#8031)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-18 06:47:05 +00:00
Johannes 16463960ad fix: flag id for A/B test re onboarding step 1 (#8035) 2026-05-18 06:45:37 +00:00
Matti Nannt 3e5a4ca4c8 refactor: consolidate unifyFeedback into feedbackDirectories license flag (#8034)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 05:28:29 +00:00
Harsh Bhat a7370ac4a0 chore: A/B experiment to test reverse trial copy (#8025)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-17 20:18:32 +00:00
Harsh Bhat 073c28e5e9 chore: A/B test different upgrade banner in trial (#7953)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 20:03:29 +00:00
Harsh Bhat 104b04bdc0 chore: A/B Test - Onboarding - Skip theme step (#7957)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-05-17 18:01:59 +00:00
Harsh Bhat e81360f354 chore: use direct form link on all "request license" buttons instead of /enterprise-license page (#8033) 2026-05-17 16:53:49 +00:00
Harsh Bhat f90a9fb131 chore: A/B experiment to remove CX/Surveys question (#7952)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:30:48 +00:00
Javi Aguilar d7627fe6c3 refactor(unify): clarify feedback source terminology and chart builder copy (#8026)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-15 20:48:31 +00:00
Dhruwang Jariwala 939fedfca4 feat: Formbricks 5 (#8017)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Javi Aguilar <122741+itsjavi@users.noreply.github.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-15 16:43:27 +00:00
Bhagya Amarasinghe 00f93eec10 fix: skip Docker package removal when Docker is already installed (#7980) 2026-05-15 15:59:35 +02:00
Bhagya Amarasinghe c286a3330a fix: rate limit storage uploads per environment (#8006) 2026-05-15 10:55:17 +00:00
Anshuman Pandey d22db8d735 fix: updates the minimum permission in projectTeam access from read to readWrite (#8020) 2026-05-15 10:13:01 +00:00
Matti Nannt 2db02dac5e docs: add ACCESSIBILITY.md and accessibility issue template (#8019)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:53:23 +00:00
Bhagya Amarasinghe ce68d58aaf fix: scope client API rate limits by environment (#8013) 2026-05-15 07:11:08 +00:00
Matti Nannt 6774f220b1 chore: remove all Vercel deployment references (#7997)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-15 07:08:58 +00:00
Anshuman Pandey efe259d484 fix: fixes displays and responses api about survey status (#8007) 2026-05-14 13:01:27 +00:00
Bhagya Amarasinghe 96b08fbe23 fix: single-use survey restriction bypass (#7972) 2026-05-14 09:11:40 +00:00
Johannes 4eba194935 fix: clarify signup product updates opt-in (#7994)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-14 07:37:19 +00:00
Dhruwang Jariwala 9d8cf5e0f7 fix(security): reject client-supplied emailVerificationDisabled in signup (ENG-816) (#7993) 2026-05-14 06:47:25 +00:00
Dhruwang Jariwala 3c6f6d83ea fix: Hungarian translation polish (ENG-935) (#8000) 2026-05-14 05:40:03 +00:00
Matti Nannt 1380c81bff fix: patch security dependency vulnerabilities for main (#7990)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:59:09 +00:00
1724 changed files with 101214 additions and 52684 deletions
-1
View File
@@ -349,4 +349,3 @@ When creating a new question element, verify:
- [ ] TypeScript types properly exported
- [ ] Error message display included if applicable
- [ ] Disabled state supported if applicable
+67 -9
View File
@@ -32,12 +32,62 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
##############
# DATABASE #
##############
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disable
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
# pinned default in the compose file; override here when testing a specific Hub release.
# HUB_IMAGE_TAG=0.3.0
# Hub embeddings are optional. Set a provider and model to enable semantic search embeddings in
# the Hub API and hub-worker. For provider-specific settings, see:
# https://hub.formbricks.com/reference/environment-variables/#embeddings
# Example with Google AI Studio:
# EMBEDDING_PROVIDER=google
# EMBEDDING_MODEL=gemini-embedding-001
# EMBEDDING_PROVIDER_API_KEY=
###########################
# CUBE ANALYTICS (XM V5) #
###########################
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
# COMPOSE_PROFILES=xm
CUBEJS_API_URL=http://localhost:4000
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
CUBEJS_API_SECRET=
CUBEJS_JWT_ISSUER=formbricks-web
CUBEJS_JWT_AUDIENCE=formbricks-cube
################
# MAIL SETUP #
################
@@ -70,7 +120,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments like Vercel
# S3 Storage is required for the file upload in serverless environments
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
@@ -150,16 +200,17 @@ AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, gcp, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=gcp
# Accepted values for AI_PROVIDER: aws, google, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
# AI_PROVIDER=google
# AI_MODEL=gemini-2.5-flash
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_APPLICATION_CREDENTIALS=
# Google Cloud settings for Gemini models
# Credentials are optional when Application Default Credentials are available.
# AI_GOOGLE_CLOUD_PROJECT=
# AI_GOOGLE_CLOUD_LOCATION=
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
@@ -278,6 +329,13 @@ REDIS_URL=redis://localhost:6379
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0
# Optional Cube.js database overrides. The official local docker-compose.dev.yml stack points Cube at the
# local `postgres` service automatically; set these only when running Cube yourself or changing bundled defaults.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
+78
View File
@@ -0,0 +1,78 @@
name: Accessibility issue
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
type: bug
labels: ["accessibility", "bug"]
body:
- type: markdown
attributes:
value: |
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
- type: textarea
id: summary
attributes:
label: Summary
description: What part of Formbricks is affected and what's wrong?
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open a survey with multiple languages
2. Press Tab repeatedly
3. Focus never lands on the language switcher
validations:
required: true
- type: input
id: wcag
attributes:
label: Related WCAG criterion (if known)
placeholder: "e.g. 2.1.1 Keyboard"
- type: dropdown
id: severity
attributes:
label: Severity
options:
- "Critical — blocks a user from completing a core task"
- "High — significant barrier with no easy workaround"
- "Medium — barrier with a workaround"
- "Low — minor friction"
validations:
required: true
- type: input
id: at
attributes:
label: Assistive technology
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
- type: input
id: browser
attributes:
label: Browser and OS
placeholder: "e.g. Firefox 138 on Windows 11"
- type: dropdown
id: environment
attributes:
label: Your Environment
options:
- Formbricks Cloud (app.formbricks.com)
- Self-hosted Formbricks
validations:
required: true
- type: textarea
id: other
attributes:
label: Other information (screenshots, recordings, axe output)
@@ -284,6 +284,10 @@ runs:
database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }}
hub_api_url=${{ env.DUMMY_HUB_API_URL }}
hub_api_key=${{ env.DUMMY_HUB_API_KEY }}
cubejs_api_url=${{ env.DUMMY_CUBEJS_API_URL }}
cubejs_api_secret=${{ env.DUMMY_CUBEJS_API_SECRET }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env:
@@ -291,6 +295,10 @@ runs:
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ env.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ env.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ env.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ env.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
+3 -5
View File
@@ -57,16 +57,14 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
+4
View File
@@ -91,5 +91,9 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
@@ -73,6 +73,10 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379
hub_api_url=http://localhost:4000
hub_api_key=build-time-placeholder
cubejs_api_url=http://localhost:4000
cubejs_api_secret=build-time-placeholder
- name: Verify and Initialize PostgreSQL
run: |
@@ -143,6 +147,10 @@ jobs:
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-e REDIS_URL="redis://host.docker.internal:6379" \
-e HUB_API_URL="http://localhost:4000" \
-e HUB_API_KEY="build-time-placeholder" \
-e CUBEJS_API_URL="http://localhost:4000" \
-e CUBEJS_API_SECRET="build-time-placeholder" \
-d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
+3 -7
View File
@@ -68,16 +68,12 @@ jobs:
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
+2 -9
View File
@@ -31,15 +31,8 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- name: Create .env
run: pnpm dev:setup
- name: Lint
run: pnpm lint
@@ -47,4 +47,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -105,4 +105,8 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+3 -7
View File
@@ -35,15 +35,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
- name: Adjust CI-specific env values
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
+3 -7
View File
@@ -32,15 +32,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
- name: Adjust CI-specific env values
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test
-1
View File
@@ -1 +0,0 @@
apps/web/.env
+48
View File
@@ -0,0 +1,48 @@
# Accessibility
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
## Standards
We aim to conform to:
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
- **Section 508** — for users in US public-sector contexts.
## Priorities
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
In both areas we focus on:
- Keyboard navigation with a clearly visible focus indicator
- Screen reader support through semantic HTML and correctly scoped ARIA
- Sufficient color and contrast
- Programmatically associated labels and announced status messages
## Supported Environments
- Latest two versions of Chrome, Firefox, Safari, and Edge
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
## Contributing
When contributing UI changes:
- Prefer semantic HTML over ARIA.
- Tab through your change end-to-end and confirm focus is visible at every stop.
- Label every control. Don't convey meaning by color alone.
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
## Reporting Accessibility Issues
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
+1
View File
@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
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.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns
+4
View File
@@ -66,6 +66,10 @@ RUN pnpm build --filter=@formbricks/database
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \
--mount=type=secret,id=hub_api_url \
--mount=type=secret,id=hub_api_key \
--mount=type=secret,id=cubejs_api_url \
--mount=type=secret,id=cubejs_api_secret \
--mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
@@ -1,20 +1,32 @@
"use client";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Loader2,
LogOutIcon,
PlusIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
@@ -22,14 +34,65 @@ import {
interface LandingSidebarProps {
user: TUser;
organization: TOrganization;
isMultiOrgEnabled: boolean;
}
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const { t } = useTranslation();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch {
setOrganizationLoadError(t("common.failed_to_load_organizations"));
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
isOrgDropdownOpen &&
organizations.length === 0 &&
!isLoadingOrganizations &&
!organizationLoadError
) {
loadOrganizations();
}
}, [
isOrgDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
const handleOrganizationChange = (orgId: string) => {
startTransition(() => {
setIsOrgDropdownOpen(false);
router.push(`/organizations/${orgId}/`);
});
};
const dropdownNavigation = [
{
label: t("common.documentation"),
@@ -39,52 +102,109 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
},
];
const switcherTriggerClasses =
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset";
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
return (
<aside
className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
<div className="flex items-center">
<div className="flex flex-col">
{/* Organization Switcher */}
<DropdownMenu onOpenChange={setIsOrgDropdownOpen}>
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && organizationLoadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
<button
onClick={() => {
setOrganizationLoadError(null);
setOrganizations([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* User Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<button
type="button"
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
aria-haspopup="menu">
<ProfileAvatar userId={user.id} />
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
{dropdownNavigation.map((link) => (
<Link
key={link.href}
id={link.href}
href={link.href}
target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -95,8 +215,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
@@ -105,7 +223,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -114,6 +231,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
</aside>
);
@@ -1,8 +1,7 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: {
@@ -24,16 +23,11 @@ const LandingLayout = async (props: {
return notFound();
}
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) {
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
return redirect(`/environments/${prodEnvironment.id}/`);
}
if (workspaces.length !== 0) {
const firstWorkspace = workspaces[0];
return redirect(`/workspaces/${firstWorkspace.id}/`);
}
return <>{children}</>;
@@ -1,9 +1,8 @@
import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -26,29 +25,25 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
return (
<div className="flex min-h-full min-w-full flex-row">
<LandingSidebar user={user} organization={organization} />
<LandingSidebar user={user} organization={organization} isMultiOrgEnabled={isMultiOrgEnabled} />
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<WorkspaceAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: {
const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
);
};
export default ProjectOnboardingLayout;
export default WorkspaceOnboardingLayout;
@@ -3,7 +3,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -40,7 +40,7 @@ const Page = async (props: ChannelPageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
@@ -59,7 +59,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -4,10 +4,10 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
@@ -32,12 +32,12 @@ const OnboardingLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id),
getOrganizationWorkspacesCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`);
}
@@ -2,7 +2,8 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -23,6 +24,13 @@ const Page = async (props: ModePageProps) => {
return redirect(`/auth/login`);
}
const experimentVariant =
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
if (experimentVariant === "remove-cx-and-surveys-mode") {
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
}
const t = await getTranslate();
const channelOptions = [
{
@@ -39,13 +47,13 @@ const Page = async (props: ModePageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -1,22 +1,17 @@
import { getTranslate } from "@/lingodotdev/server";
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
import { type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
interface SelectPlanOnboardingProps {
organizationId: string;
variant: TPlanVariant;
}
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
const t = await getTranslate();
export const SelectPlanOnboarding = ({ organizationId, variant }: Readonly<SelectPlanOnboardingProps>) => {
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} variant={variant} />
</div>
);
};
@@ -1,11 +1,14 @@
import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { PLAN_VARIANTS, type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const VALID_VARIANTS = new Set<TPlanVariant>(PLAN_VARIANTS);
interface PlanPageProps {
params: Promise<{
@@ -36,7 +39,24 @@ const Page = async (props: PlanPageProps) => {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
let variant: TPlanVariant = "control";
const flagValue = await getPostHogFeatureFlag(
session.user.id,
"a-b_onboarding_trial-conversion-screen-copy",
{
organizationId: params.organizationId,
}
);
if (typeof flagValue === "string" && VALID_VARIANTS.has(flagValue as TPlanVariant)) {
variant = flagValue as TPlanVariant;
}
const selectPlanOnboardingProps = {
organizationId: params.organizationId,
variant,
};
return <SelectPlanOnboarding {...selectPlanOnboardingProps} />;
};
export default Page;
@@ -8,20 +8,20 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
TProjectMode,
TProjectUpdateInput,
ZProjectUpdateInput,
} from "@formbricks/types/project";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
TWorkspaceUpdateInput,
ZWorkspaceUpdateInput,
} from "@formbricks/types/workspace";
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
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";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import {
@@ -37,34 +37,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps {
interface WorkspaceSettingsProps {
organizationId: string;
projectMode: TProjectMode;
channel: TProjectConfigChannel;
industry: TProjectConfigIndustry;
workspaceMode: TWorkspaceMode;
channel: TWorkspaceConfigChannel;
industry: TWorkspaceConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
userWorkspacesCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
export const WorkspaceSettings = ({
organizationId,
projectMode,
workspaceMode,
channel,
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
userWorkspacesCount,
publicDomain,
}: ProjectSettingsProps) => {
}: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
@@ -72,7 +72,7 @@ export const ProjectSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
const createWorkspaceResponse = await createWorkspaceAction({
organizationId,
data: {
...data,
@@ -82,26 +82,21 @@ export const ProjectSettings = ({
},
});
if (createProjectResponse?.data) {
// get production environment
const productionEnvironment = createProjectResponse.data.environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
if (createWorkspaceResponse?.data) {
if (globalThis.window !== undefined) {
// Remove filters when creating a new workspace
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
const workspaceId = createWorkspaceResponse.data.id;
if (channel === "app" || channel === "website") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
router.push(`/workspaces/${workspaceId}/connect`);
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (projectMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
router.push(`/workspaces/${workspaceId}/surveys`);
} else if (workspaceMode === "cx") {
router.push(`/workspaces/${workspaceId}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProjectResponse);
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage);
}
} catch (error) {
@@ -110,15 +105,15 @@ export const ProjectSettings = ({
}
};
const form = useForm<TProjectUpdateInput>({
const form = useForm<TWorkspaceUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
resolver: zodResolver(ZWorkspaceUpdateInput),
});
const projectName = form.watch("name");
const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -133,7 +128,7 @@ export const ProjectSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
@@ -185,7 +180,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField
control={form.control}
name="teamIds"
@@ -243,7 +238,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -2,31 +2,38 @@ import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
interface ProjectSettingsPageProps {
interface WorkspaceSettingsPageProps {
params: Promise<{
organizationId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
mode?: TProjectMode;
channel?: TWorkspaceConfigChannel;
industry?: TWorkspaceConfigIndustry;
mode?: TWorkspaceMode;
}>;
}
const Page = async (props: ProjectSettingsPageProps) => {
const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -40,7 +47,28 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const experimentVariant =
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-theme-screen")) || "control";
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (experimentVariant === "remove-theme") {
const existing = workspaces.find((w) => w.name === organization.name);
const workspace =
existing ??
(await createWorkspace(params.organizationId, {
name: organization.name,
styling: buildStylingFromBrandColor(DEFAULT_BRAND_COLOR),
config: { channel, industry },
}));
if (channel === "app" || channel === "website") {
return redirect(`/workspaces/${workspace.id}/connect`);
} else if (channel === "link") {
return redirect(`/workspaces/${workspace.id}/surveys`);
}
return redirect(`/workspaces/${workspace.id}/xm-templates`);
}
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
@@ -70,18 +98,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
<WorkspaceSettings
organizationId={params.organizationId}
projectMode={mode}
workspaceMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
userWorkspacesCount={workspaces.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -4,21 +4,20 @@ import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps {
environment: TEnvironment;
workspaceId: string;
publicDomain: string;
appSetupCompleted: boolean;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
}
export const ConnectWithFormbricks = ({
environment,
workspaceId,
publicDomain,
appSetupCompleted,
channel,
@@ -26,7 +25,7 @@ export const ConnectWithFormbricks = ({
const { t } = useTranslation();
const router = useRouter();
const handleFinishOnboarding = async () => {
router.push(`/environments/${environment.id}/surveys`);
router.push(`/workspaces/${workspaceId}/surveys`);
};
useEffect(() => {
@@ -48,7 +47,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
workspaceId={workspaceId}
publicDomain={publicDomain}
channel={channel}
appSetupCompleted={appSetupCompleted}
@@ -61,9 +60,9 @@ export const ConnectWithFormbricks = ({
)}>
{appSetupCompleted ? (
<div>
<p className="text-3xl">{t("environments.connect.congrats")}</p>
<p className="text-3xl">{t("workspace.connect.congrats")}</p>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.connection_successful_message")}
{t("workspace.connect.connection_successful_message")}
</p>
</div>
) : (
@@ -73,7 +72,7 @@ export const ConnectWithFormbricks = ({
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.waiting_for_your_signal")}
{t("workspace.connect.waiting_for_your_signal")}
</p>
</div>
)}
@@ -83,9 +82,7 @@ export const ConnectWithFormbricks = ({
id="finishOnboarding"
variant={appSetupCompleted ? "default" : "ghost"}
onClick={handleFinishOnboarding}>
{appSetupCompleted
? t("environments.connect.finish_onboarding")
: t("environments.connect.do_it_later")}
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
<ArrowRight />
</Button>
</div>
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -17,14 +17,14 @@ const tabs = [
];
interface OnboardingSetupInstructionsProps {
environmentId: string;
workspaceId: string;
publicDomain: string;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean;
}
export const OnboardingSetupInstructions = ({
environmentId,
workspaceId,
publicDomain,
channel,
appSetupCompleted,
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
const npmSnippetForAppSurveys = `
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
const npmSnippetForWebsiteSurveys = `
// other imports
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
return (
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
{t("workspace.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
{t("workspace.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
@@ -1,56 +1,50 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ConnectPageProps {
params: Promise<{
environmentId: string;
workspaceId: string;
}>;
}
const Page = async (props: ConnectPageProps) => {
const params = await props.params;
const t = await getTranslate();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const channel = project.config.channel || null;
const channel = workspace.config.channel || null;
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} />
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<ConnectWithFormbricks
environment={environment}
workspaceId={params.workspaceId}
publicDomain={publicDomain}
appSetupCompleted={environment.appSetupCompleted}
appSetupCompleted={workspace.appSetupCompleted}
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>
<Link href={`/workspaces/${params.workspaceId}`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
@@ -1,11 +1,11 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
return redirect(`/auth/login`);
}
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
if (!isAuthorized) {
throw new AuthorizationError("User is not authorized to access this environment");
throw new AuthorizationError("User is not authorized to access this workspace");
}
return <div className="flex-1 bg-slate-50">{children}</div>;
@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps {
project: TProject;
workspace: TWorkspace;
user: TUser;
environmentId: string;
workspaceId: string;
}
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
createdBy: user.id,
};
const createSurveyResponse = await createSurveyAction({
environmentId: environmentId,
workspaceId: workspaceId,
surveyBody: augmentedTemplate,
});
if (createSurveyResponse?.data) {
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
} else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage);
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project);
const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate);
};
const XMTemplateOptions = [
{
title: t("environments.xm-templates.nps"),
description: t("environments.xm-templates.nps_description"),
title: t("workspace.xm-templates.nps"),
description: t("workspace.xm-templates.nps_description"),
icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0,
},
{
title: t("environments.xm-templates.five_star_rating"),
description: t("environments.xm-templates.five_star_rating_description"),
title: t("workspace.xm-templates.five_star_rating"),
description: t("workspace.xm-templates.five_star_rating_description"),
icon: StarIcon,
onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1,
},
{
title: t("environments.xm-templates.csat"),
description: t("environments.xm-templates.csat_description"),
title: t("workspace.xm-templates.csat"),
description: t("workspace.xm-templates.csat_description"),
icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2,
},
{
title: t("environments.xm-templates.ces"),
description: t("environments.xm-templates.ces_description"),
title: t("workspace.xm-templates.ces"),
description: t("workspace.xm-templates.ces_description"),
icon: ActivityIcon,
onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3,
},
{
title: t("environments.xm-templates.smileys"),
description: t("environments.xm-templates.smileys_description"),
title: t("workspace.xm-templates.smileys"),
description: t("workspace.xm-templates.smileys_description"),
icon: SmileIcon,
onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4,
},
{
title: t("environments.xm-templates.enps"),
description: t("environments.xm-templates.enps_description"),
title: t("workspace.xm-templates.enps"),
description: t("workspace.xm-templates.enps_description"),
icon: UsersIcon,
onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5,
@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
const mockWorkspace: TWorkspace = {
id: "workspace1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
name: "Test Workspace",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
@@ -27,12 +27,12 @@ const mockProject: TProject = {
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
environments: [],
appSetupCompleted: false,
languages: [],
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
name: "$[workspaceName] Survey",
blocks: [
{
id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
headline: { default: "$[workspaceName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
],
styling: {
brandColor: { light: "#0000FF" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
elementHeadlineColor: { light: "#00FF00" },
inputBgColor: { light: "#FF0000" },
},
};
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Workspace Survey");
});
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});
@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
// replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
};
@@ -2,11 +2,9 @@ import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
@@ -14,15 +12,15 @@ import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps {
params: Promise<{
environmentId: string;
workspaceId: string;
}>;
}
const Page = async (props: XMTemplatePageProps) => {
const params = await props.params;
const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
@@ -31,29 +29,24 @@ const Page = async (props: XMTemplatePageProps) => {
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projects = await getUserProjects(session.user.id, organizationId);
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
@@ -1,37 +0,0 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorEnvironmentLayout;
@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getWorkspace } from "@/lib/workspace/service";
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
const SurveyEditorWorkspaceLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorWorkspaceLayout;
@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
export const ConfirmationPage = () => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
useEffect(() => {
setShowConfetti(true);
@@ -20,11 +20,9 @@ export const ConfirmationPage = () => {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
if (storedWorkspaceId) {
setResolvedWorkspaceId(storedWorkspaceId);
}
}, []);
@@ -43,9 +41,7 @@ export const ConfirmationPage = () => {
<Button asChild className="w-full justify-center">
<Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/"
}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
@@ -1,4 +1,4 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
export const LoadingCard = ({
@@ -1,18 +0,0 @@
"use client";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
interface EnvironmentStorageHandlerProps {
environmentId: string;
}
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
}, [environmentId]);
return null;
};
export default EnvironmentStorageHandler;
@@ -1,56 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface EnvironmentSwitchProps {
environment: TEnvironment;
environments: TEnvironment[];
}
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentType: "production" | "development") => {
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
if (newEnvironmentId) {
router.push(`/environments/${newEnvironmentId}/`);
}
};
const toggleEnvSwitch = () => {
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
setIsLoading(true);
setIsEnvSwitchChecked(!isEnvSwitchChecked);
handleEnvironmentChange(newEnvironmentType);
};
return (
<div
className={cn(
"flex items-center space-x-2 rounded-lg p-2",
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
)}>
<Label
htmlFor="development-mode"
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
{t("common.dev_env")}
</Label>
<Switch
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
id="development-mode"
disabled={isLoading}
checked={isEnvSwitchChecked}
onCheckedChange={toggleEnvSwitch}
/>
</div>
);
};
@@ -1,837 +0,0 @@
"use client";
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Cog,
FoldersIcon,
Loader2,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import {
getOrganizationsForSwitcherAction,
getProjectsForSwitcherAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import packageJson from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
project: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
if (pathname.includes("/settings/")) {
return false;
}
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
if (accountSettingsPattern.test(pathname)) {
return false;
}
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const MainNavigation = ({
environment,
organization,
user,
project,
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
isMultiOrgEnabled,
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const [isPending, startTransition] = useTransition();
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const disabledNavigationMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
const isOwnerOrManager = isManager || isOwner;
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
};
useEffect(() => {
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
setIsCollapsed(isCollapsedValueFromLocalStorage);
}, []);
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
}, [isCollapsed]);
useEffect(() => {
// Auto collapse project navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
}, [pathname]);
const mainNavigation = useMemo(
() => [
{
name: t("common.surveys"),
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/workspace"),
disabled: isMembershipPending || isBilling,
},
],
[t, environment.id, pathname, isMembershipPending, isBilling]
);
const dropdownNavigation = [
{
label: t("common.account"),
href: `/environments/${environment.id}/settings/profile`,
icon: UserCircleIcon,
},
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
},
{
label: t("common.share_feedback"),
href: "https://github.com/formbricks/formbricks/issues",
target: "_blank",
icon: ArrowUpRightIcon,
},
];
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${environment.id}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${environment.id}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${environment.id}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${environment.id}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${environment.id}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${environment.id}/workspace/tags`,
},
];
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${environment.id}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environment.id}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environment.id}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environment.id}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environment.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
setWorkspaceLoadError(null);
try {
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setWorkspaceLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
);
} finally {
setIsLoadingProjects(false);
setHasInitializedProjects(true);
}
}, [organization.id, t]);
useEffect(() => {
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
return;
}
loadProjects();
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setOrganizationLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
);
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
!isOrganizationDropdownOpen ||
organizations.length > 0 ||
isLoadingOrganizations ||
organizationLoadError
) {
return;
}
loadOrganizations();
}, [
isOrganizationDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
useEffect(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
if (res?.data) {
const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`;
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
setLatestVersion(latestVersionTag);
}
}
}
if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = isBilling
? getBillingFallbackPath(environment.id, isFormbricksCloud)
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleProjectCreate = () => {
if (!hasInitializedProjects || isLoadingProjects) {
return;
}
if (projects.length >= organizationProjectsLimit) {
setOpenProjectLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${environment.id}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${environment.id}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
};
const switcherTriggerClasses = cn(
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
return (
<>
{project && (
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
)}>
<div>
{/* Logo and Toggle */}
<div className="flex items-center justify-between px-3 pb-4">
{!isCollapsed && (
<Link
href={mainNavigationLink}
className={cn(
"flex items-center justify-center transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
) : (
<PanelLeftCloseIcon strokeWidth={1.5} />
)}
</Button>
</div>
{/* Main Nav Switch */}
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
</div>
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingProjects || isInitialProjectsLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects &&
!isInitialProjectsLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setProjects([]);
},
t("common.try_again")
)}
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === project.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleProjectCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
if (setting.hidden) return null;
return (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button
type="button"
aria-label={isCollapsed ? t("common.account_settings") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p
title={user?.email}
className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearEnvironmentId: true,
});
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")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</aside>
)}
{openProjectLimitModal && (
<ProjectLimitModal
open={openProjectLimitModal}
setOpen={setOpenProjectLimitModal}
buttons={projectLimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</>
);
};
@@ -1,89 +0,0 @@
"use client";
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
export const EnvironmentBreadcrumb = ({
environments,
currentEnvironment,
}: {
environments: { id: string; type: string }[];
currentEnvironment: { id: string; type: string };
}) => {
const { t } = useTranslation();
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentId: string) => {
if (environmentId === currentEnvironment.id) return;
setIsLoading(true);
router.push(`/environments/${environmentId}/`);
};
const developmentTooltip = () => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<CircleHelpIcon className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
{t("common.development_environment_banner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
return (
<BreadcrumbItem
isActive={isEnvironmentDropdownOpen}
isHighlighted={currentEnvironment.type === "development"}>
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="environmentDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
<span className="capitalize">{currentEnvironment.type}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{currentEnvironment.type === "development" && developmentTooltip()}
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2" align="start">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Code2Icon className="mr-2 inline h-4 w-4" />
{t("common.choose_environment")}
</div>
<DropdownMenuGroup>
{environments.map((env) => (
<DropdownMenuCheckboxItem
key={env.id}
checked={env.type === currentEnvironment.type}
onClick={() => handleEnvironmentChange(env.id)}
className="cursor-pointer">
<div className="flex items-center gap-2 capitalize">
<span>{env.type}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
);
};
@@ -1,81 +0,0 @@
"use client";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
)}
{showEnvironmentBreadcrumb && (
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
)}
</BreadcrumbList>
</Breadcrumb>
);
};
@@ -1,344 +0,0 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
}
// Check if path matches /workspace/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: ProjectBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
// Lazy-load projects when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${currentEnvironmentId}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${currentEnvironmentId}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${currentEnvironmentId}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
];
const areProjectSettingsDisabled = isMembershipPending || isBilling;
const projectSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
startTransition(() => {
setIsProjectDropdownOpen(false);
router.push(targetPath);
});
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${currentEnvironmentId}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMembershipPending || !isOwnerOrManager ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action")}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<div key={setting.id}>
{areProjectSettingsDisabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{projectSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
</BreadcrumbItem>
);
};
@@ -1,68 +0,0 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
export const useProject = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
}
return { project: context.project };
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organization,
organizationId: project.organizationId,
}),
[environment, project, organization]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};
@@ -1,48 +0,0 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
// Check session first (required for userId)
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Single consolidated data fetch (replaces ~12 individual fetches)
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.project.id}
workspaceName={layoutData.project.name}
/>
)}
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</>
);
};
export default EnvLayout;
@@ -1,38 +0,0 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
};
export default AccountSettingsLayout;
@@ -1,6 +0,0 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import Loading from "@/modules/organization/settings/api-keys/loading";
export default function LoadingPage() {
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
@@ -1,82 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { getAccessFlags } from "@/lib/membership/utils";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface OrganizationSettingsNavbarProps {
environmentId?: string;
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
activeId: string;
loading?: boolean;
}
export const OrganizationSettingsNavbar = ({
environmentId,
isFormbricksCloud,
membershipRole,
activeId,
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const isMembershipPending = membershipRole === undefined || loading;
const { t } = useTranslation();
const navigation = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environmentId}/settings/general`,
current: pathname?.includes("/general"),
hidden: false,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environmentId}/settings/domain`,
current: pathname?.includes("/domain"),
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud,
current: pathname?.includes("/billing"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
current: pathname?.includes("/enterprise"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -1,36 +0,0 @@
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Loading = async () => {
const t = await getTranslate();
const cards = [
{
title: t("environments.settings.general.organization_name"),
description: t("environments.settings.general.organization_name_description"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
},
{
title: t("environments.settings.general.delete_organization"),
description: t("environments.settings.general.delete_organization_description"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
},
];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="general" loading />
</PageHeader>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</PageContentWrapper>
);
};
export default Loading;
@@ -1,35 +0,0 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
};
export default Layout;
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};
export default Page;
@@ -1,50 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Confetti } from "@/modules/ui/components/confetti";
export const SuccessMessage = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
const isAppSurvey = survey.type === "app";
const appSetupCompleted = environment.appSetupCompleted;
useEffect(() => {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
isAppSurvey && !appSetupCompleted
? t("environments.surveys.summary.almost_there")
: t("environments.surveys.summary.congrats"),
{
id: "survey-publish-success-toast",
icon: isAppSurvey && !appSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
}
);
// Remove success param from url
const url = new URL(window.location.href);
url.searchParams.delete("success");
if (survey.type === "link") {
// Add share param to url to open share embed modal
url.searchParams.set("share", "true");
}
window.history.replaceState({}, "", url.toString());
}
}, [environment, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
return <>{confetti && <Confetti />}</>;
};
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
};
export default Page;
@@ -1,3 +0,0 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;
@@ -1,3 +0,0 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
export default AppConnectionPage;
@@ -1,3 +0,0 @@
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
export default GeneralSettingsLoading;
@@ -1,3 +0,0 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
export default GeneralSettingsPage;
@@ -1,3 +0,0 @@
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading;
@@ -1,3 +0,0 @@
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;
@@ -1,4 +0,0 @@
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata };
export default ProjectSettingsLayout;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
export default ProjectLookSettingsLoading;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
export default ProjectLookSettingsPage;
@@ -1,3 +0,0 @@
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
export default ProjectSettingsPage;
@@ -1,3 +0,0 @@
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
export default TagsLoading;
@@ -1,3 +0,0 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
export default TagsPage;
@@ -1,3 +0,0 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
export default ProjectTeams;
@@ -0,0 +1,8 @@
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <ChartsListPage workspaceId={workspaceId} />;
};
export default ChartsPage;
@@ -0,0 +1,7 @@
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
return <DashboardDetailPage params={props.params} />;
};
export default Page;
@@ -0,0 +1,8 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <DashboardsListPage workspaceId={workspaceId} />;
};
export default DashboardsPage;
@@ -0,0 +1,3 @@
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
export default AnalysisListLoading;
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
export default async function FeedbackSourcesRedirect(
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) {
const { workspaceId } = await props.params;
redirect(`/workspaces/${workspaceId}/settings/workspace/feedback-sources`);
}
@@ -7,30 +7,30 @@ import {
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getOrganizationWorkspacesLimit,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateProjectAction = z.object({
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZProjectUpdateInput,
data: ZWorkspaceUpdateInput,
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
@@ -41,7 +41,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
schema: ZWorkspaceUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -54,10 +54,10 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
throw new ResourceNotFoundError("Organization", organizationId);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
@@ -69,7 +69,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
@@ -81,28 +81,28 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", project.id, { name: project.name });
groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: project.id,
name: project.name,
workspace_id: workspace.id,
name: workspace.name,
},
{ organizationId, workspaceId: project.id }
{ organizationId, workspaceId: workspace.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
ctx.auditLoggingCtx.workspaceId = workspace.id;
ctx.auditLoggingCtx.newObject = workspace;
return workspace;
})
);
const ZGetOrganizationsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
organizationId: ZId, // Changed from workspaceId to avoid extra query
});
/**
@@ -126,16 +126,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
return await getOrganizationsByUserId(ctx.user.id);
});
const ZGetProjectsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
const ZGetWorkspacesForSwitcherAction = z.object({
organizationId: ZId, // Changed from workspaceId to avoid extra query
});
/**
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the project switcher.
* Fetches workspaces list for switcher dropdown.
* Called on-demand when user opens the workspace switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction)
export const getWorkspacesForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetWorkspacesForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -148,11 +148,11 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
],
});
// Need membership for getProjectsByUserId (1 DB query)
// Need membership for getWorkspacesByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
return await getWorkspacesByUserId(ctx.user.id, membership);
});
@@ -0,0 +1,869 @@
"use client";
import {
ArrowUpRightIcon,
BarChart3Icon,
Building2Icon,
ChevronRightIcon,
Cog,
FoldersIcon,
Loader2,
LogOutIcon,
MessageCircle,
MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import {
getOrganizationsForSwitcherAction,
getWorkspacesForSwitcherAction,
} from "@/app/(app)/workspaces/[workspaceId]/actions";
import { NavigationLink } from "@/app/(app)/workspaces/[workspaceId]/components/NavigationLink";
import { SettingsSidebarContent } from "@/app/(app)/workspaces/[workspaceId]/components/SettingsSidebarContent";
import { isNewerVersion } from "@/app/(app)/workspaces/[workspaceId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { TRIAL_BASE_RESPONSE_LIMIT, TrialBannerNew } from "@/modules/ee/billing/components/trial-banner-new";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
import packageJson from "../../../../../package.json";
interface NavigationProps {
user: TUser;
organization: TOrganization;
workspace: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
responseCount: number;
newTrialBannerVariant: string | boolean;
}
export const MainNavigation = ({
organization,
user,
workspace,
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
isMultiOrgEnabled,
organizationWorkspacesLimit,
isLicenseActive,
isAccessControlAllowed,
responseCount,
newTrialBannerVariant,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const [isPending, startTransition] = useTransition();
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const disabledNavigationMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
const isOwnerOrManager = isManager || isOwner;
const isSettingsMode = pathname?.includes("/settings");
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
};
useEffect(() => {
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
setIsCollapsed(isCollapsedValueFromLocalStorage);
}, []);
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
}, [isCollapsed]);
const mainNavigationSections = useMemo(
() => [
{
id: "ask",
name: t("common.ask"),
items: [
{
name: t("common.surveys"),
href: `/workspaces/${workspace.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/workspaces/${workspace.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
],
},
{
id: "unify-feedback",
name: (
<span className="inline-flex items-center gap-2">
<span>{t("workspace.unify.unify_feedback")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="text-[10px] font-semibold normal-case tracking-normal"
/>
</span>
),
items: [
{
name: t("workspace.unify.feedback_records"),
href: `/workspaces/${workspace.id}/unify/feedback-records`,
icon: MessageSquareTextIcon,
isActive: pathname?.includes("/unify/feedback-records"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
name: t("common.dashboards"),
href: `/workspaces/${workspace.id}/dashboards`,
icon: BarChart3Icon,
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
}),
[t, workspace.id, isSettingsMode, isMembershipPending, isBilling]
);
const dropdownNavigation = [
{
label: t("common.account"),
href: `/workspaces/${workspace.id}/settings/account/profile`,
icon: UserCircleIcon,
},
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
},
{
label: t("common.share_feedback"),
href: "https://github.com/formbricks/formbricks/issues",
target: "_blank",
icon: ArrowUpRightIcon,
},
];
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
const [hasInitializedWorkspaces, setHasInitializedWorkspaces] = useState(false);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [openWorkspaceLimitModal, setOpenWorkspaceLimitModal] = useState(false);
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
);
const loadWorkspaces = useCallback(async () => {
setIsLoadingWorkspaces(true);
setWorkspaceLoadError(null);
try {
const result = await getWorkspacesForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setWorkspaces(sorted);
} else {
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setWorkspaceLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
);
} finally {
setIsLoadingWorkspaces(false);
setHasInitializedWorkspaces(true);
}
}, [organization.id, t]);
useEffect(() => {
if (!isWorkspaceDropdownOpen || workspaces.length > 0 || isLoadingWorkspaces || workspaceLoadError) {
return;
}
loadWorkspaces();
}, [isWorkspaceDropdownOpen, workspaces.length, isLoadingWorkspaces, workspaceLoadError, loadWorkspaces]);
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setOrganizationLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
);
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
!isOrganizationDropdownOpen ||
organizations.length > 0 ||
isLoadingOrganizations ||
organizationLoadError
) {
return;
}
loadOrganizations();
}, [
isOrganizationDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
useEffect(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
if (res?.data) {
const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`;
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
setLatestVersion(latestVersionTag);
}
}
}
if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = isBilling
? getBillingFallbackPath(workspace.id, isFormbricksCloud)
: `/workspaces/${workspace.id}/surveys/`;
const handleWorkspaceChange = (workspaceId: string) => {
const targetPath =
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/workspaces/${workspace.id}/settings/organization/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleWorkspaceCreate = () => {
if (!hasInitializedWorkspaces || isLoadingWorkspaces) {
return;
}
if (workspaces.length >= organizationWorkspacesLimit) {
setOpenWorkspaceLimitModal(true);
return;
}
setOpenCreateWorkspaceModal(true);
};
const workspaceLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("workspace.settings.billing.upgrade"),
href: `/workspaces/${workspace.id}/settings/organization/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenWorkspaceLimitModal(false),
},
];
}
return [
{
text: t("workspace.settings.billing.upgrade"),
href: isLicenseActive
? `/workspaces/${workspace.id}/settings/organization/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenWorkspaceLimitModal(false),
},
];
};
const handleSettingsWorkspaceChange = useCallback(
(id: string) => {
startTransition(() => {
router.push(`/workspaces/${id}/settings/workspace/general`);
});
},
[router]
);
const handleSettingsOrganizationChange = useCallback(
(id: string) => {
startTransition(() => {
if (id === organization.id) {
router.push(`/workspaces/${workspace.id}/settings/organization/general`);
} else {
router.push(`/organizations/${id}/`);
}
});
},
[router, organization.id, workspace.id]
);
const switcherTriggerClasses = cn(
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialWorkspacesLoading =
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
return (
<>
{workspace && (
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
isSettingsMode || !isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
)}>
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton />
</div>
{/* Settings sidebar content */}
<SettingsSidebarContent
workspaceId={workspace.id}
workspaceName={workspace.name}
organizationId={organization.id}
organizationName={organization.name}
membershipRole={membershipRole}
isFormbricksCloud={isFormbricksCloud}
isCollapsed={false}
isTextVisible={false}
workspaces={workspaces}
isLoadingWorkspaces={isLoadingWorkspaces}
onWorkspaceChange={handleSettingsWorkspaceChange}
onWorkspaceDropdownOpen={loadWorkspaces}
organizations={organizations}
isLoadingOrganizations={isLoadingOrganizations}
onOrganizationChange={handleSettingsOrganizationChange}
onOrganizationDropdownOpen={loadOrganizations}
/>
</div>
) : (
<div>
{/* Logo and Toggle */}
<div className="flex items-center justify-between px-3 pb-4">
{!isCollapsed && (
<Link
href={mainNavigationLink}
className={cn(
"flex items-center justify-center transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
) : (
<PanelLeftCloseIcon strokeWidth={1.5} />
)}
</Button>
</div>
{/* Main Nav */}
<ul className="space-y-2">
{mainNavigationSections.map((section) => (
<li key={section.id}>
{!isCollapsed && !isTextVisible && (
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
{section.name}
</p>
)}
<ul>
{section.items.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
</li>
))}
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
<ul>
<NavigationLink
href={settingsNavigationItem.href}
isActive={settingsNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={settingsNavigationItem.disabled}
disabledMessage={
settingsNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={settingsNavigationItem.name}>
<settingsNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</ul>
</li>
</ul>
</div>
)}
{!isSettingsMode && (
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed &&
isFormbricksCloud &&
trialDaysRemaining !== null &&
(newTrialBannerVariant === "new-trial-banner" ? (
<TrialBannerNew
trialDaysRemaining={trialDaysRemaining}
planName={organization.billing.stripe?.plan ?? "pro"}
responseCount={responseCount}
responseLimit={organization.billing.limits.monthly.responses}
baseResponseLimit={TRIAL_BASE_RESPONSE_LIMIT}
billingHref={`/workspaces/${workspace.id}/settings/organization/billing`}
hasPaymentMethod={organization.billing.stripe?.hasPaymentMethod}
/>
) : (
<Link
href={`/workspaces/${workspace.id}/settings/organization/billing`}
className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
))}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger
asChild
id="workspaceDropdownTrigger"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{workspace.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces &&
!isInitialWorkspacesLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setWorkspaces([]);
},
t("common.try_again")
)}
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === workspace.id}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleWorkspaceCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
}
className="cursor-pointer">
<Cog className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button
type="button"
aria-label={isCollapsed ? t("common.account_settings") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p
title={user?.email}
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearWorkspaceId: true,
});
router.push(route?.url || loginUrl);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</aside>
)}
{openWorkspaceLimitModal && (
<WorkspaceLimitModal
open={openWorkspaceLimitModal}
setOpen={setOpenWorkspaceLimitModal}
buttons={workspaceLimitModalButtons()}
workspaceLimit={organizationWorkspacesLimit}
/>
)}
{openCreateWorkspaceModal && (
<CreateWorkspaceModal
open={openCreateWorkspaceModal}
setOpen={setOpenCreateWorkspaceModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</>
);
};
@@ -0,0 +1,450 @@
"use client";
import {
BellIcon,
BlocksIcon,
BrushIcon,
Building2Icon,
ChevronDownIcon,
CreditCardIcon,
FoldersIcon,
GlobeIcon,
KeyIcon,
LanguagesIcon,
ListChecksIcon,
Loader2,
ShapesIcon,
ShieldIcon,
TagIcon,
UnplugIcon,
UserCircleIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface SettingsSidebarContentProps {
workspaceId: string;
workspaceName: string;
organizationId: string;
organizationName: string;
membershipRole?: TOrganizationRole;
isFormbricksCloud: boolean;
isCollapsed: boolean;
isTextVisible: boolean;
// Workspace switcher
workspaces: { id: string; name: string }[];
isLoadingWorkspaces: boolean;
onWorkspaceChange: (id: string) => void;
onWorkspaceDropdownOpen: () => void;
// Organization switcher
organizations: { id: string; name: string }[];
isLoadingOrganizations: boolean;
onOrganizationChange: (id: string) => void;
onOrganizationDropdownOpen: () => void;
}
interface NavItem {
id: string;
label: string;
href: string;
icon: React.ReactNode;
hidden?: boolean;
disabled?: boolean;
}
const SettingsNavLink = ({
item,
isActive,
isCollapsed,
isTextVisible,
disabledMessage,
}: {
item: NavItem;
isActive: boolean;
isCollapsed: boolean;
isTextVisible: boolean;
disabledMessage?: string;
}) => {
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const isDisabled = item.disabled;
const getStateClass = () => {
if (isDisabled) return disabledClass;
return isActive ? activeClass : inactiveClass;
};
if (isCollapsed) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li className={cn("rounded-l-md py-1.5 pl-2 text-sm", getStateClass())}>
{isDisabled ? (
<div className="flex items-center">{item.icon}</div>
) : (
<Link href={item.href} className="flex items-center text-slate-600 hover:text-slate-900">
{item.icon}
</Link>
)}
</li>
</TooltipTrigger>
<TooltipContent side="right">
{isDisabled ? disabledMessage || item.label : item.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (isDisabled) {
return (
<li className={cn("rounded-l-md py-1.5 pl-8 text-sm", disabledClass)}>
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center">
{item.icon}
<span
className={cn(
"ml-2 transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{item.label}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{disabledMessage || item.label}
</PopoverContent>
</Popover>
</li>
);
}
return (
<li
className={cn(
"rounded-l-md py-1.5 pl-8 text-sm",
isActive ? activeClass : inactiveClass,
"text-slate-600 hover:text-slate-900"
)}>
<Link href={item.href} className="flex items-center">
{item.icon}
<span
className={cn("ml-2 transition-opacity duration-100", isTextVisible ? "opacity-0" : "opacity-100")}>
{item.label}
</span>
</Link>
</li>
);
};
const SectionHeader = ({
icon,
label,
isCollapsed,
isTextVisible,
switcherName,
switcherItems,
isLoadingSwitcher,
currentId,
onSwitcherChange,
onSwitcherOpen,
}: {
icon: React.ReactNode;
label: string;
isCollapsed: boolean;
isTextVisible: boolean;
switcherName?: string;
switcherItems?: { id: string; name: string }[];
isLoadingSwitcher?: boolean;
currentId?: string;
onSwitcherChange?: (id: string) => void;
onSwitcherOpen?: () => void;
}) => {
if (isCollapsed) {
return <div className="mb-1 mt-3 flex justify-center px-2 text-slate-400">{icon}</div>;
}
return (
<div
className={cn(
"mb-1 mt-4 flex min-w-0 items-center gap-2 px-3",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<span className="text-slate-500">{icon}</span>
<span className="shrink-0 text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</span>
{switcherName && switcherItems && onSwitcherChange && (
<DropdownMenu onOpenChange={(open) => open && onSwitcherOpen?.()}>
<DropdownMenuTrigger className="ml-auto flex min-w-0 max-w-[50%] items-center gap-1 rounded-md border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50">
<span className="truncate">{switcherName}</span>
<ChevronDownIcon className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px]">
{isLoadingSwitcher ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<DropdownMenuGroup className="overflow-y-auto">
{switcherItems.map((item) => (
<DropdownMenuCheckboxItem
key={item.id}
checked={item.id === currentId}
onClick={() => onSwitcherChange(item.id)}
className="cursor-pointer text-sm">
{item.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};
export const SettingsSidebarContent = ({
workspaceId,
workspaceName,
organizationId,
organizationName,
membershipRole,
isFormbricksCloud,
isCollapsed,
isTextVisible,
workspaces,
isLoadingWorkspaces,
onWorkspaceChange,
onWorkspaceDropdownOpen,
organizations,
isLoadingOrganizations,
onOrganizationChange,
onOrganizationDropdownOpen,
}: SettingsSidebarContentProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { isMember, isBilling, isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const iconClassName = "h-4 w-4 shrink-0";
const basePath = `/workspaces/${workspaceId}/settings`;
const workspaceItems: NavItem[] = [
{
id: "general",
label: t("common.general"),
href: `${basePath}/workspace/general`,
icon: <FoldersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "teams",
label: t("common.team_access"),
href: `${basePath}/workspace/teams`,
icon: <UsersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `${basePath}/workspace/languages`,
icon: <LanguagesIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "app-connection",
label: t("common.connect_your_app"),
href: `${basePath}/workspace/app-connection`,
icon: <UnplugIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `${basePath}/workspace/feedback-sources`,
icon: <ShapesIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "integrations",
label: t("common.integrations"),
href: `${basePath}/workspace/integrations`,
icon: <BlocksIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "look",
label: t("common.appearance"),
href: `${basePath}/workspace/look`,
icon: <BrushIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "user-actions",
label: t("common.user_actions"),
href: `${basePath}/workspace/user-actions`,
icon: <ListChecksIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "tags",
label: t("common.tags"),
href: `${basePath}/workspace/tags`,
icon: <TagIcon className={iconClassName} />,
disabled: isBilling,
},
];
const organizationItems: NavItem[] = [
{
id: "org-general",
label: t("common.general"),
href: `${basePath}/organization/general`,
icon: <Building2Icon className={iconClassName} />,
disabled: isBilling,
},
{
id: "org-teams",
label: t("common.teams"),
href: `${basePath}/organization/teams`,
icon: <UsersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "org-feedback-directories",
label: t("workspace.settings.feedback_directories.nav_label"),
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
},
{
id: "org-api-keys",
label: t("common.api_keys"),
href: `${basePath}/organization/api-keys`,
icon: <KeyIcon className={iconClassName} />,
hidden: !isOwnerOrManager,
},
{
id: "org-domain",
label: t("common.domain"),
href: `${basePath}/organization/domain`,
icon: <GlobeIcon className={iconClassName} />,
hidden: isFormbricksCloud,
},
{
id: "org-billing",
label: t("common.billing"),
href: `${basePath}/organization/billing`,
icon: <CreditCardIcon className={iconClassName} />,
hidden: !isFormbricksCloud,
},
{
id: "org-enterprise",
label: t("common.enterprise_license"),
href: `${basePath}/organization/enterprise`,
icon: <ShieldIcon className={iconClassName} />,
hidden: isFormbricksCloud,
disabled: isMember || isBilling,
},
];
const accountItems: NavItem[] = [
{
id: "profile",
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
},
];
const disabledMessage = t("common.you_are_not_authorized_to_perform_this_action");
const renderSection = (items: NavItem[]) => {
const visibleItems = items.filter((item) => !item.hidden);
return (
<ul className="space-y-0.5">
{visibleItems.map((item) => (
<SettingsNavLink
key={item.id}
item={item}
isActive={pathname.includes(item.href)}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabledMessage={item.disabled ? disabledMessage : undefined}
/>
))}
</ul>
);
};
return (
<div className="flex flex-col overflow-y-auto">
<div>
<SectionHeader
icon={<FoldersIcon className="h-4 w-4" />}
label={t("common.workspace")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
switcherName={workspaceName}
switcherItems={workspaces}
isLoadingSwitcher={isLoadingWorkspaces}
currentId={workspaceId}
onSwitcherChange={onWorkspaceChange}
onSwitcherOpen={onWorkspaceDropdownOpen}
/>
{renderSection(workspaceItems)}
</div>
<div>
<SectionHeader
icon={<Building2Icon className="h-4 w-4" />}
label={t("common.organization")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
switcherName={organizationName}
switcherItems={organizations}
isLoadingSwitcher={isLoadingOrganizations}
currentId={organizationId}
onSwitcherChange={onOrganizationChange}
onSwitcherOpen={onOrganizationDropdownOpen}
/>
{renderSection(organizationItems)}
</div>
<div>
<SectionHeader
icon={<UserCircleIcon className="h-4 w-4" />}
label={t("common.account")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
/>
{renderSection(accountItems)}
</div>
</div>
);
};
@@ -1,17 +1,13 @@
"use client";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
currentProjectId: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
@@ -20,37 +16,30 @@ interface TopControlBarProps {
}
export const TopControlBar = ({
environments,
currentOrganizationId,
currentProjectId,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
membershipRole,
}: TopControlBarProps) => {
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { workspace } = useWorkspaceContext();
const isMembershipPending = membershipRole === undefined;
const { environment } = useEnvironment();
return (
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<ProjectAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
<WorkspaceAndOrgSwitch
currentWorkspaceId={workspace.id}
currentOrganizationId={currentOrganizationId}
currentProjectId={currentProjectId}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed}
/>
@@ -3,33 +3,32 @@
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
interface WidgetStatusIndicatorProps {
environment: TEnvironment;
workspace: { appSetupCompleted: boolean };
}
export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps) => {
const { t } = useTranslation();
const router = useRouter();
const stati = {
notImplemented: {
icon: AlertTriangleIcon,
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
title: t("workspace.app-connection.formbricks_sdk_not_connected"),
subtitle: t("workspace.app-connection.formbricks_sdk_not_connected_description"),
},
running: {
icon: CheckIcon,
title: t("environments.workspace.app-connection.receiving_data"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
title: t("workspace.app-connection.receiving_data"),
subtitle: t("workspace.app-connection.formbricks_sdk_connected"),
},
};
let status: "notImplemented" | "running";
if (environment.appSetupCompleted) {
if (workspace.appSetupCompleted) {
status = "running";
} else {
status = "notImplemented";
@@ -57,7 +56,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />
{t("environments.workspace.app-connection.recheck")}
{t("workspace.app-connection.recheck")}
</Button>
)}
</div>
@@ -1,34 +1,33 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { MainNavigation } from "@/app/(app)/workspaces/[workspaceId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { TWorkspaceLayoutData } from "@/modules/workspaces/types/workspace-auth";
interface EnvironmentLayoutProps {
layoutData: TEnvironmentLayoutData;
interface WorkspaceLayoutProps {
layoutData: TWorkspaceLayoutData;
children?: React.ReactNode;
}
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
user,
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
workspace, // Current workspace details
isAccessControlAllowed,
projectPermission,
workspacePermission,
license,
responseCount,
} = layoutData;
@@ -38,55 +37,50 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const newTrialBannerVariant = await getPostHogFeatureFlag(user.id, "a-b_navigation_rich-trial-banner");
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
if (isMember && !projectPermission) {
// Validate that workspace permission exists for members
if (isMember && !workspacePermission) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
{IS_FORMBRICKS_CLOUD && (
<LimitsReachedBanner
organization={organization}
environmentId={environment.id}
responseCount={responseCount}
/>
<LimitsReachedBanner organization={organization} responseCount={responseCount} />
)}
<PendingDowngradeBanner
lastChecked={lastChecked}
isPendingDowngrade={isPendingDowngrade ?? false}
active={active}
environmentId={environment.id}
locale={user.locale}
status={status}
/>
<div className="flex h-full">
<MainNavigation
environment={environment}
organization={organization}
user={user}
project={{ id: project.id, name: project.name }}
workspace={{ id: workspace.id, name: workspace.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
responseCount={responseCount}
newTrialBannerVariant={newTrialBannerVariant}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
currentProjectId={project.id}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}
@@ -1,13 +1,13 @@
import Link from "next/link";
import { ReactNode } from "react";
interface ProjectNavItemProps {
interface WorkspaceNavItemProps {
href: string;
children: ReactNode;
isActive: boolean;
}
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
const activeClass = "bg-slate-50 font-semibold";
const inactiveClass = "hover:bg-slate-50";
@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS, FORMBRICKS_WORKSPACE_ID_LS } from "@/lib/localStorage";
interface WorkspaceStorageHandlerProps {
workspaceId: string;
}
const WorkspaceStorageHandler = ({ workspaceId }: WorkspaceStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_WORKSPACE_ID_LS, workspaceId);
// Keep legacy environment ID in sync for backward compatibility with old SDK clients
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, workspaceId);
}, [workspaceId]);
return null;
};
export default WorkspaceStorageHandler;
@@ -9,11 +9,11 @@ import {
PlusIcon,
SettingsIcon,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
@@ -25,45 +25,24 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization } from "../context/environment-context";
import { useOrganization, useWorkspace } from "../context/workspace-context";
interface OrganizationBreadcrumbProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: pass directly if context not available
isMultiOrgEnabled: boolean;
currentEnvironmentId?: string;
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
isMembershipPending: boolean;
currentWorkspaceId?: string;
}
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
// Match /settings/{settingId} or /settings/{settingId}/... but exclude account settings
// Exclude paths with /(account)/
if (pathname.includes("/(account)/")) {
return false;
}
// Check if path matches /settings/{settingId} (with optional trailing path)
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const OrganizationBreadcrumb = ({
currentOrganizationId,
currentOrganizationName,
isMultiOrgEnabled,
currentEnvironmentId,
isFormbricksCloud,
isMember,
isOwnerOrManager,
isMembershipPending,
currentWorkspaceId,
}: OrganizationBreadcrumbProps) => {
const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const pathname = usePathname();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
@@ -73,6 +52,7 @@ export const OrganizationBreadcrumb = ({
// Get current organization name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { organization: currentOrganization } = useOrganization();
const { workspace } = useWorkspace();
const organizationName = currentOrganization?.name || currentOrganizationName || "";
// Lazy-load organizations when dropdown opens
@@ -113,11 +93,13 @@ export const OrganizationBreadcrumb = ({
return;
}
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const handleOrganizationChange = (organizationId: string) => {
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentEnvironmentId) {
router.push(`/environments/${currentEnvironmentId}/settings/general`);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/organization/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
@@ -134,50 +116,6 @@ export const OrganizationBreadcrumb = ({
});
};
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${currentEnvironmentId}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
},
];
return (
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
@@ -246,43 +184,16 @@ export const OrganizationBreadcrumb = ({
)}
</>
)}
{currentEnvironmentId && (
<div>
{currentWorkspaceId && (
<>
{showOrganizationDropdown && <DropdownMenuSeparator />}
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
return setting.hidden ? null : (
<div key={setting.id}>
{setting.disabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{setting.disabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
);
})}
</div>
<DropdownMenuCheckboxItem
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -0,0 +1,60 @@
"use client";
import { OrganizationBreadcrumb } from "@/app/(app)/workspaces/[workspaceId]/components/organization-breadcrumb";
import { WorkspaceBreadcrumb } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface WorkspaceAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentWorkspaceId?: string;
currentWorkspaceName?: string; // Optional: for pages without context
isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isAccessControlAllowed: boolean;
isMembershipPending: boolean;
}
export const WorkspaceAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentWorkspaceId,
currentWorkspaceName,
isMultiOrgEnabled,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMembershipPending,
}: WorkspaceAndOrgSwitchProps) => {
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
/>
{currentWorkspaceId && (
<WorkspaceBreadcrumb
currentWorkspaceId={currentWorkspaceId}
currentWorkspaceName={currentWorkspaceName}
currentOrganizationId={currentOrganizationId}
isOwnerOrManager={isOwnerOrManager}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={false}
isMembershipPending={isMembershipPending}
/>
)}
</BreadcrumbList>
</Breadcrumb>
);
};
@@ -0,0 +1,263 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getWorkspacesForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { useWorkspace } from "../context/workspace-context";
interface WorkspaceBreadcrumbProps {
currentWorkspaceId: string;
currentWorkspaceName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
isMembershipPending: boolean;
}
export const WorkspaceBreadcrumb = ({
currentWorkspaceId,
currentWorkspaceName,
isOwnerOrManager,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
isMembershipPending,
}: WorkspaceBreadcrumbProps) => {
const { t } = useTranslation();
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Get current workspace name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { workspace: currentWorkspace } = useWorkspace();
const workspaceName = currentWorkspace?.name || currentWorkspaceName || "";
const workspaceBasePath = `/workspaces/${currentWorkspace?.id}`;
// Lazy-load workspaces when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isWorkspaceDropdownOpen && workspaces.length === 0 && !isLoadingWorkspaces && !loadError) {
setIsLoadingWorkspaces(true);
setLoadError(null); // Clear any previous errors
getWorkspacesForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort workspaces by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setWorkspaces(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load workspaces");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingWorkspaces(false);
});
}
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
if (!currentWorkspace) {
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleWorkspaceChange = (workspaceId: string) => {
const targetPath =
workspaceId === currentWorkspaceId
? `/workspaces/${currentWorkspaceId}/surveys`
: `/workspaces/${workspaceId}/`;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleAddWorkspace = () => {
if (workspaces.length >= organizationWorkspacesLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateWorkspaceModal(true);
};
const handleWorkspaceSettingsNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("workspace.settings.billing.upgrade"),
href: `${workspaceBasePath}/settings/organization/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text: t("workspace.settings.billing.upgrade"),
href: isLicenseActive
? `${workspaceBasePath}/settings/organization/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
};
return (
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}>
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger className="flex cursor-pointer items-center gap-1 outline-none" asChild>
<div className="flex items-center gap-1">
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{workspaceName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingWorkspaces && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setWorkspaces([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingWorkspaces && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((ws) => (
<DropdownMenuCheckboxItem
key={ws.id}
checked={ws.id === currentWorkspaceId}
onClick={() => handleWorkspaceChange(ws.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{ws.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMembershipPending || !isOwnerOrManager ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action")}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
onClick={handleAddWorkspace}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleWorkspaceSettingsNavigation(`${workspaceBasePath}/settings/workspace/general`)
}
className="cursor-pointer">
<CogIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<WorkspaceLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
workspaceLimit={organizationWorkspacesLimit}
/>
)}
{openCreateWorkspaceModal && (
<CreateWorkspaceModal
open={openCreateWorkspaceModal}
setOpen={setOpenCreateWorkspaceModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
</BreadcrumbItem>
);
};
@@ -0,0 +1,61 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TWorkspace } from "@formbricks/types/workspace";
export interface WorkspaceContextType {
workspace: TWorkspace;
organization: TOrganization;
organizationId: string;
}
const WorkspaceContext = createContext<WorkspaceContextType | null>(null);
export const useWorkspaceContext = () => {
const context = useContext(WorkspaceContext);
if (!context) {
throw new Error("useWorkspaceContext must be used within a WorkspaceContextWrapper");
}
return context;
};
export const useWorkspace = () => {
const context = useContext(WorkspaceContext);
if (!context) {
return { workspace: null };
}
return { workspace: context.workspace };
};
export const useOrganization = () => {
const context = useContext(WorkspaceContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface WorkspaceContextWrapperProps {
workspace: TWorkspace;
organization: TOrganization;
children: React.ReactNode;
}
export const WorkspaceContextWrapper = ({
workspace,
organization,
children,
}: WorkspaceContextWrapperProps) => {
const workspaceContextValue = useMemo(
() => ({
workspace,
organization,
organizationId: workspace.organizationId,
}),
[workspace, organization]
);
return <WorkspaceContext.Provider value={workspaceContextValue}>{children}</WorkspaceContext.Provider>;
};
@@ -0,0 +1,43 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { WorkspaceLayout as WorkspaceLayoutComponent } from "@/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout";
import { WorkspaceContextWrapper } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getWorkspaceLayoutData } from "@/modules/workspaces/lib/utils";
import WorkspaceStorageHandler from "./components/WorkspaceStorageHandler";
const WorkspaceLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect(`/auth/login`);
}
const layoutData = await getWorkspaceLayoutData(params.workspaceId, session.user.id);
return (
<>
<WorkspaceStorageHandler workspaceId={params.workspaceId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.workspace.id}
workspaceName={layoutData.workspace.name}
/>
)}
<WorkspaceContextWrapper workspace={layoutData.workspace} organization={layoutData.organization}>
<WorkspaceLayoutComponent layoutData={layoutData}>{children}</WorkspaceLayoutComponent>
</WorkspaceContextWrapper>
</>
);
};
export default WorkspaceLayout;

Some files were not shown because too many files have changed in this diff Show More