Compare commits

..

61 Commits

Author SHA1 Message Date
Johannes b123965d93 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-24 10:38:32 +02:00
Johannes aed47b94a8 feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-24 10:37:26 +02:00
Johannes ecaa2887b7 refactor: align connector enum with formbricks_survey
Rename connector type usage from formbricks to formbricks_survey across Prisma schema, shared types, and connector service logic to keep enum contracts consistent.

Made-with: Cursor
2026-04-24 10:36:16 +02:00
Johannes ad094b2d4c fix setup + tweak chart & dashboards creation UX 2026-04-22 09:04:42 +02:00
Dhruwang Jariwala b099219244 feat: connect charts with FeedbackRecordDirectories (ENG-665) (#7760) 2026-04-20 16:46:36 +05:30
Dhruwang af82329c4a fix: add padding to FrdPicker component for improved UI 2026-04-17 14:24:23 +05:30
Dhruwang cfff6b1495 feat: connect charts with FeedbackRecordDirectories (ENG-665)
Scope every chart to a specific FeedbackRecordDirectory so Cube.js
queries are filtered by tenant_id. This ensures data isolation when
multiple FRDs exist in a workspace.

- Add feedbackRecordDirectoryId FK to Chart model + migration
- Add tenantId dimension to FeedbackRecords Cube schema
- Inject tenant filter server-side before every Cube.js query execution
- Require FRD selection when creating charts (FrdPicker component)
- Show FRD as immutable field when editing charts
- Pass feedbackRecordDirectoryId through all chart creation flows
- Update tests and add injectTenantFilter test coverage
- Add i18n keys for FRD selection UI to all locale files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:52:22 +05:30
Dhruwang 9bc4e69821 Merge remote-tracking branch 'origin/feat/connect-frd-with-connectors' into epic/dashboards
# Conflicts:
#	apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
#	apps/web/locales/de-DE.json
#	apps/web/locales/nl-NL.json
#	apps/web/locales/ro-RO.json
#	apps/web/locales/ru-RU.json
#	packages/database/schema.prisma
2026-04-17 12:28:10 +05:30
pandeymangg 81b1c036f6 fixes 2026-04-17 10:50:20 +05:30
pandeymangg 635200db78 fix e2e tests 2026-04-16 17:30:57 +05:30
pandeymangg 793320746e fix build 2026-04-16 16:31:15 +05:30
Dhruwang Jariwala 6501041a48 chore: merge epic/v5 into epic/dashboards (#7750) 2026-04-16 16:22:05 +05:30
pandeymangg f5337e77f3 fixes tests 2026-04-16 16:20:55 +05:30
pandeymangg 0192c1ed00 fixes tests 2026-04-16 16:05:31 +05:30
Dhruwang 2e2b13c36b fix: add CUBEJS and OPENAI env vars to turbo.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:01:08 +05:30
Dhruwang 95dd4404d1 Merge remote-tracking branch 'origin/epic/v5' into epic/dashboards 2026-04-16 15:54:05 +05:30
pandeymangg 8a9912f839 fixes feedback 2026-04-16 15:40:02 +05:30
Dhruwang Jariwala 016dc3d92a feat: Dashboards & Charts (#7390)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-16 12:51:08 +04:00
pandeymangg 3a147a2b09 fixes 2026-04-16 11:14:26 +05:30
pandeymangg e159b45911 fixes translations 2026-04-14 18:02:01 +05:30
pandeymangg 96d14b98f0 build fixes 2026-04-14 17:55:44 +05:30
pandeymangg aa90d9fd1a chore: merge with epic/v5 2026-04-14 17:17:14 +05:30
pandeymangg 2ffe79ffd2 chore: merge with epic/v5 2026-04-14 13:23:04 +05:30
Anshuman Pandey cffeb0513e feat: feedback records table (#7422)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-09 09:11:18 +01:00
Dhruwang Jariwala bc334c24cf feat: add Formbricks AI toggle to organization settings (#7399)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-05 06:56:57 -08:00
Anshuman Pandey 077a9934ad feat: csv connector (#7361)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-05 13:57:14 +01:00
Tiago 1ed8d8076e chore: remove unused postgres-init-dev volume mount from dev compose (#7412) 2026-03-04 17:34:51 +01:00
Dhruwang Jariwala 345b282733 chore: sync epic dashboards with main (#7398) 2026-03-02 13:48:04 +05:30
Dhruwang c7c30a9d58 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into sync/epic-dashboards-with-main 2026-03-02 12:48:41 +05:30
Dhruwang 08510659de chore: merge main into sync branch to update epic/dashboards 2026-03-02 12:45:58 +05:30
Dhruwang Jariwala f8fa29d56e feat: charts ui (#7332)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 12:21:48 +00:00
Anshuman Pandey 8b048c3105 fix: polish formbricks connector (#7348)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 13:08:26 +01:00
Anshuman Pandey b2705a4f8f feat: use @formbricks/hub SDK instead of custom hub-client (#7357) 2026-02-26 13:22:52 +04:00
pandeymangg e867caa373 adds tests for hub-client 2026-02-26 14:34:16 +05:30
Theodór Tómas ff6176df0a chore: sync epic/dashboards with main (#7368) 2026-02-26 15:24:39 +07:00
TheodorTomas d0f4228b45 chore: resolve merge conflicts syncing main into epic/dashboards
- segments.ts: take main's sequential WHERE clause building to prevent pool saturation
- package.json: update zod to 3.25.76 from main
- pnpm-lock.yaml: resolve zod version references, keep @suspensive/react from epic
2026-02-26 13:46:39 +07:00
Tiago Farto de79b58648 chore: fix lock file 2026-02-25 17:28:11 +00:00
Tiago Farto 04d528b9b8 chore: fix lock file 2026-02-25 17:22:56 +00:00
Tiago Farto c815b11015 chore: refactored hub client 2026-02-25 17:19:53 +00:00
Tiago Farto 1e7830d850 chore: refactored hub initialization 2026-02-25 13:38:19 +00:00
Tiago Farto 77cd1e9bd1 chore: additional test coverage 2026-02-25 12:46:05 +00:00
Tiago Farto e665227437 feat(connector): use @formbricks/hub SDK instead of custom hub-client
- Remove apps/web/lib/connector/hub-client.ts
- Add @formbricks/hub dependency to apps/web
- pipeline-handler: create Hub client from env (HUB_API_KEY, HUB_API_URL), call SDK create in batch with same results shape
- transform: use FormbricksHub.FeedbackRecordCreateParams from SDK directly

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:20:46 +00:00
Dhruwang Jariwala 3a802810e3 feat: Charts list page with demo create/edit and real delete/duplicate (#7353)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-25 11:20:34 +00:00
Dhruwang Jariwala fbbf917093 chore: sync dashboard epic (#7351)
Signed-off-by: gulshank0 <gulshanbahadur002@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: Cursor <cursoragent@cursor.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: Theodór Tómas <theodortomas@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>
2026-02-25 13:24:51 +05:30
Dhruwang a7e42bfd29 Merge main into epic/dashboards to sync with latest changes 2026-02-25 12:11:43 +05:30
Dhruwang 562fdec899 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-25 12:08:10 +05:30
Harsh Bhat 75e71e39bc feat: Unify POC hackathon (#7169)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-24 17:59:28 +01:00
Tiago 337aedf463 feat: Add Formbricks Hub to Docker Compose and Helm chart (#7284) 2026-02-23 14:35:33 +01:00
Theodór Tómas d670d5de31 feat: (dashboards) listing page (#7330) 2026-02-23 20:26:03 +07:00
Theodór Tómas 5ccb4af249 feat: (dashboards) crud charts/dashboard server actions (#7307)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-23 11:44:26 +05:30
Theodór Tómas 62aa186a81 chore: merge main into dashboard epic (#7321)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 12:22:54 +00:00
Dhruwang cb094761ca Merge branch 'main' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-20 17:38:35 +05:30
Theodór Tómas f35e54f21d feat: (dashboards) adding analysis tab to sidebar along with placeholder pages (#7311) 2026-02-20 09:58:26 +05:30
Theodór Tómas f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas 9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang 4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang 1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:17:44 +05:30
Dhruwang 4f9088559f feat: add Cube.js dev setup and analytics client
- Add Cube container to docker-compose.dev.yml (pinned v1.3.21)
- Add Cube server config (cube/cube.js) and FeedbackRecords schema
- Add @cubejs-client/core dependency and singleton client in EE module
- Add CUBEJS_API_URL and CUBEJS_API_TOKEN to .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:05:47 +05:30
Dhruwang 18550f1d11 feat: link Chart and Dashboard createdBy to User
- Add creator relation on Chart and Dashboard to User
- Add createdBy foreign key constraints in migration (ON DELETE SET NULL)
- Mirror Survey pattern for createdBy user tracking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:26:42 +05:30
Dhruwang 881cd31f74 feat: add Chart, Dashboard, DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- ChartType: area, bar, line, pie, big_number only
- Remove DashboardStatus and WidgetType (widgets are always charts)
- DashboardWidget requires chartId, remove content/type fields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:24:48 +05:30
Dhruwang e00405dca2 feat: add Chart, Dashboard, and DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- Add ChartType, DashboardStatus, WidgetType enums
- Add migration 20260128111722 for charts and dashboards tables

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
852 changed files with 15612 additions and 43131 deletions
+21 -44
View File
@@ -32,24 +32,6 @@ 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 #
##############
@@ -64,21 +46,6 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?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.2.0
###########################
# 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 #
@@ -182,17 +149,16 @@ 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, google, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
# AI_PROVIDER=google
# 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
# AI_MODEL=gemini-2.5-flash
# 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=
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
@@ -311,13 +277,24 @@ 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.
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
@@ -284,10 +284,6 @@ 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:
@@ -295,10 +291,6 @@ 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 }}
+5 -3
View File
@@ -57,14 +57,16 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
- name: Create .env
run: pnpm dev:setup
- name: create .env
run: cp .env.example .env
shell: bash
- name: Fill E2E_TESTING in .env
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and 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,9 +91,5 @@ 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,10 +73,6 @@ 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: |
+7 -3
View File
@@ -68,12 +68,16 @@ jobs:
run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash
- name: Create .env
run: pnpm dev:setup
- name: create .env
run: cp .env.example .env
shell: bash
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill ENCRYPTION_KEY, 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
+9 -2
View File
@@ -31,8 +31,15 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env
run: pnpm dev:setup
- 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: Lint
run: pnpm lint
@@ -47,8 +47,4 @@ 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,8 +105,4 @@ 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 }}
+7 -3
View File
@@ -35,11 +35,15 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env
run: pnpm dev:setup
- name: create .env
run: cp .env.example .env
- name: Adjust CI-specific env values
- 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
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
+7 -3
View File
@@ -32,11 +32,15 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env
run: pnpm dev:setup
- name: create .env
run: cp .env.example .env
- name: Adjust CI-specific env values
- 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
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test
+1 -1
View File
@@ -23,7 +23,7 @@
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.2",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
}
}
-4
View File
@@ -66,10 +66,6 @@ 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,32 +1,20 @@
"use client";
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Loader2,
LogOutIcon,
PlusIcon,
} from "lucide-react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useState } 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";
@@ -34,65 +22,14 @@ import {
interface LandingSidebarProps {
user: TUser;
organization: TOrganization;
isMultiOrgEnabled: boolean;
}
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();
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
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"),
@@ -102,11 +39,6 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
},
];
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(
@@ -114,97 +46,45 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
)}>
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
<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 */}
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
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>
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} />
<div className="grow overflow-hidden">
<p
title={user?.email}
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
className={cn(
"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>
<p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link
key={link.href}
id={link.href}
href={link.href}
target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -215,6 +95,8 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
@@ -231,7 +113,6 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
</aside>
);
@@ -3,6 +3,7 @@ import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organiza
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";
@@ -25,11 +26,12 @@ 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} isMultiOrgEnabled={isMultiOrgEnabled} />
<LandingSidebar user={user} organization={organization} />
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
@@ -43,6 +45,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
</div>
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
],
styling: {
brandColor: { light: "#0000FF" },
elementHeadlineColor: { light: "#00FF00" },
inputBgColor: { light: "#FF0000" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
},
};
@@ -39,10 +39,7 @@ export const ConfirmationPage = () => {
</p>
</div>
<Button asChild className="w-full justify-center">
<Link
href={
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/"
}>
<Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>
@@ -1,8 +1 @@
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`);
}
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
@@ -17,9 +17,9 @@ import {
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/airtable";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -5,8 +5,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/airtable";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import airtableLogo from "@/images/airtableLogo.svg";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
@@ -40,7 +40,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/settings/workspace/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("workspace.integrations.airtable.airtable_integration")} />
<div className="h-[75vh] w-full">
<AirtableWrapper
@@ -12,13 +12,13 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import {
constructGoogleSheetsUrl,
extractSpreadsheetIdFromUrl,
isValidGoogleSheetsUrl,
} from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/lib/util";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
@@ -7,9 +7,9 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/lib/google";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -9,7 +9,7 @@ import {
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
@@ -39,7 +39,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/settings/workspace/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("workspace.integrations.google_sheets.google_sheets_integration")} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper
@@ -15,12 +15,12 @@ import {
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import {
MappingRow,
TMapping,
createEmptyMapping,
} from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/MappingRow";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -8,7 +8,7 @@ import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/constants";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/constants";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { Button } from "@/modules/ui/components/button";
@@ -8,8 +8,8 @@ import {
} from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/ManageIntegration";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/ManageIntegration";
import notionLogo from "@/images/notion.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { authorize } from "../lib/notion";
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/NotionWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
import Image from "next/image";
import { redirect } from "next/navigation";
import { TIntegrationType } from "@formbricks/types/integration";
import { getWebhookCountBySource } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/webhook";
import { getWebhookCountBySource } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/webhook";
import ActivePiecesLogo from "@/images/activepieces.webp";
import AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
@@ -21,6 +21,7 @@ import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
const getStatusText = (count: number, t: TFunction, type: string) => {
if (count === 1) return `1 ${type}`;
@@ -80,7 +81,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
disabled: isReadOnly,
},
{
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/webhooks`,
connectHref: `/workspaces/${params.workspaceId}/integrations/webhooks`,
connectText: t("workspace.integrations.manage_webhooks"),
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
@@ -94,7 +95,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
disabled: false,
},
{
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/google-sheets`,
connectHref: `/workspaces/${params.workspaceId}/integrations/google-sheets`,
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
@@ -108,7 +109,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
disabled: isReadOnly,
},
{
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/airtable`,
connectHref: `/workspaces/${params.workspaceId}/integrations/airtable`,
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
@@ -122,7 +123,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
disabled: isReadOnly,
},
{
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/slack`,
connectHref: `/workspaces/${params.workspaceId}/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
@@ -164,7 +165,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
disabled: isReadOnly,
},
{
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/notion`,
connectHref: `/workspaces/${params.workspaceId}/integrations/notion`,
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
@@ -197,7 +198,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/app-connection`,
connectHref: `/workspaces/${params.workspaceId}/app-connection`,
connectText: t("common.connect"),
connectNewTab: false,
label: "Javascript SDK",
@@ -210,7 +211,9 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.integrations")} />
<PageHeader pageTitle={t("common.workspace_configuration")}>
<WorkspaceConfigNavigation activeId="integrations" />
</PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
{integrationCards.map((card) => (
<Card
@@ -15,7 +15,7 @@ import {
} from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
@@ -5,10 +5,10 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSlackChannelsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/lib/slack";
import { getSlackChannelsAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/lib/slack";
import slackLogo from "@/images/slacklogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/SlackWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/SlackWrapper";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
@@ -31,7 +31,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/settings/workspace/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("workspace.integrations.slack.slack_integration")} />
<div className="h-[75vh] w-full">
<SlackWrapper
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
feedbackRecordDirectoryId: ZId.optional(),
});
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZWorkspaceUpdateInput,
data: ZCreateWorkspaceInput,
});
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [
{
data: parsedInput.data,
schema: ZWorkspaceUpdateInput,
schema: ZCreateWorkspaceInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -117,7 +121,7 @@ const ZGetWorkspacesForSwitcherAction = z.object({
});
/**
* Fetches workspaces list for switcher dropdown.
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the workspace switcher.
*/
export const getWorkspacesForSwitcherAction = authenticatedActionClient
@@ -32,7 +32,6 @@ import {
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";
@@ -53,7 +52,6 @@ import {
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";
@@ -74,6 +72,25 @@ interface NavigationProps {
isAccessControlAllowed: boolean;
}
const isActiveWorkspaceSetting = (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 = ({
organization,
user,
@@ -96,14 +113,13 @@ export const MainNavigation = ({
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const [isPending, startTransition] = useTransition();
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
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 isSettingsMode = pathname?.includes("/settings");
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
@@ -123,11 +139,18 @@ export const MainNavigation = ({
return () => clearTimeout(timeoutId);
}, [isCollapsed]);
useEffect(() => {
// Auto collapse workspace navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
}, [pathname]);
const mainNavigationSections = useMemo(
() => [
{
id: "ask",
name: t("common.ask"),
name: "Ask",
items: [
{
name: t("common.surveys"),
@@ -175,21 +198,29 @@ export const MainNavigation = ({
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const settingsNavigationItem = useMemo(
const configurationNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
icon: SettingsIcon,
isActive: isSettingsMode,
name: t("common.configuration"),
href: `/workspaces/${workspace.id}/general`,
icon: Cog,
isActive:
pathname?.includes("/general") ||
pathname?.includes("/look") ||
pathname?.includes("/app-connection") ||
pathname?.includes("/feedback-sources") ||
pathname?.includes("/integrations") ||
pathname?.includes("/teams") ||
pathname?.includes("/languages") ||
pathname?.includes("/tags"),
disabled: isMembershipPending || isBilling,
}),
[t, workspace.id, isSettingsMode, isMembershipPending, isBilling]
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const dropdownNavigation = [
{
label: t("common.account"),
href: `/workspaces/${workspace.id}/settings/account/profile`,
href: `/workspaces/${workspace.id}/settings/profile`,
icon: UserCircleIcon,
},
{
@@ -228,6 +259,86 @@ export const MainNavigation = ({
</div>
);
const workspaceSettings = [
{
id: "general",
label: t("common.general"),
href: `/workspaces/${workspace.id}/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/workspaces/${workspace.id}/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/workspaces/${workspace.id}/app-connection`,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `/workspaces/${workspace.id}/feedback-sources`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/workspaces/${workspace.id}/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/workspaces/${workspace.id}/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/workspaces/${workspace.id}/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/workspaces/${workspace.id}/tags`,
},
];
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/workspaces/${workspace.id}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/workspaces/${workspace.id}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/workspaces/${workspace.id}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/workspaces/${workspace.id}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/workspaces/${workspace.id}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/workspaces/${workspace.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
const loadWorkspaces = useCallback(async () => {
setIsLoadingWorkspaces(true);
setWorkspaceLoadError(null);
@@ -342,22 +453,16 @@ export const MainNavigation = ({
: `/workspaces/${workspace.id}/surveys/`;
const handleWorkspaceChange = (workspaceId: string) => {
const targetPath =
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
if (workspaceId === workspace.id) return;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
router.push(`/workspaces/${workspaceId}/`);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/workspaces/${workspace.id}/settings/organization/general`
: `/organizations/${organizationId}/`;
if (organizationId === organization.id) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
router.push(`/organizations/${organizationId}/`);
});
};
@@ -385,7 +490,7 @@ export const MainNavigation = ({
return [
{
text: t("workspace.settings.billing.upgrade"),
href: `/workspaces/${workspace.id}/settings/organization/billing`,
href: `/workspaces/${workspace.id}/settings/billing`,
},
{
text: t("common.cancel"),
@@ -398,7 +503,7 @@ export const MainNavigation = ({
{
text: t("workspace.settings.billing.upgrade"),
href: isLicenseActive
? `/workspaces/${workspace.id}/settings/organization/enterprise`
? `/workspaces/${workspace.id}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
@@ -408,35 +513,13 @@ export const MainNavigation = ({
];
};
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";
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialWorkspacesLoading =
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
@@ -446,371 +529,351 @@ export const MainNavigation = ({
<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"
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
)}>
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
<div>
{/* Logo and Toggle */}
{/* 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}
<div className="flex items-center justify-between px-3 pb-4">
{!isCollapsed && (
<Link
href={mainNavigationLink}
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"
"flex items-center justify-center transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
) : (
<PanelLeftCloseIcon strokeWidth={1.5} />
<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 Switch */}
<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>
)}
</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>
{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>
</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>
)}
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
<NavigationLink
href={configurationNavigationItem.href}
isActive={configurationNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={configurationNavigationItem.disabled}
disabledMessage={
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={configurationNavigationItem.name}>
<configurationNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</li>
</ul>
</div>
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link
href={`/workspaces/${workspace.id}/settings/organization/billing`}
className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
<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>
)}
<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 && (
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/workspaces/${workspace.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 && (
<>
<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>
<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} />
</>
)}
<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")}
</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>
{isLoadingOrganizations && (
<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")
)}
{!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 && (
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => (
<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} />
key={proj.id}
checked={proj.id === workspace.id}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
{proj.name}
</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} />
</>
))}
</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>
)}
</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>
</>
)}
<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>
{workspaceSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveWorkspaceSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
<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>
</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,
clearWorkspaceId: 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>
)}
{openWorkspaceLimitModal && (
@@ -1,450 +0,0 @@
"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>
);
};
@@ -3,6 +3,7 @@
import { TOrganizationRole } from "@formbricks/types/memberships";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps {
currentOrganizationId: string;
@@ -25,6 +26,7 @@ export const TopControlBar = ({
isAccessControlAllowed,
membershipRole,
}: TopControlBarProps) => {
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { workspace } = useWorkspaceContext();
const isMembershipPending = membershipRole === undefined;
@@ -40,6 +42,8 @@ export const TopControlBar = ({
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed}
/>
@@ -9,7 +9,7 @@ import {
PlusIcon,
SettingsIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -25,6 +25,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization, useWorkspace } from "../context/workspace-context";
interface OrganizationBreadcrumbProps {
@@ -32,17 +33,37 @@ interface OrganizationBreadcrumbProps {
currentOrganizationName?: string; // Optional: pass directly if context not available
isMultiOrgEnabled: boolean;
currentWorkspaceId?: string;
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
isMembershipPending: boolean;
}
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,
currentWorkspaceId,
isFormbricksCloud,
isMember,
isOwnerOrManager,
isMembershipPending,
}: 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);
@@ -96,12 +117,8 @@ export const OrganizationBreadcrumb = ({
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/organization/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -116,6 +133,56 @@ export const OrganizationBreadcrumb = ({
});
};
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `${workspaceBasePath}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `${workspaceBasePath}/settings/teams`,
},
{
id: "feedback-record-directories",
label: t("workspace.settings.feedback_record_directories.nav_label"),
href: `${workspaceBasePath}/settings/feedback-record-directories`,
hidden: isMember,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `${workspaceBasePath}/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: `${workspaceBasePath}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `${workspaceBasePath}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `${workspaceBasePath}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
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}>
@@ -185,15 +252,42 @@ export const OrganizationBreadcrumb = ({
</>
)}
{currentWorkspaceId && (
<>
<div>
{showOrganizationDropdown && <DropdownMenuSeparator />}
<DropdownMenuCheckboxItem
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</>
<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>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -14,7 +14,9 @@ interface WorkspaceAndOrgSwitchProps {
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isAccessControlAllowed: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
@@ -29,6 +31,8 @@ export const WorkspaceAndOrgSwitch = ({
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
isBilling,
isMembershipPending,
}: WorkspaceAndOrgSwitchProps) => {
return (
@@ -39,6 +43,10 @@ export const WorkspaceAndOrgSwitch = ({
currentOrganizationName={currentOrganizationName}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/>
{currentWorkspaceId && (
<WorkspaceBreadcrumb
@@ -51,6 +59,7 @@ export const WorkspaceAndOrgSwitch = ({
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={false}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
)}
@@ -2,7 +2,7 @@
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -33,9 +33,20 @@ interface WorkspaceBreadcrumbProps {
currentOrganizationId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => {
// Match /{settingId} or /{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
}
// Check if path matches /workspaces/{id}/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspaces/[^/]+/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const WorkspaceBreadcrumb = ({
currentWorkspaceId,
currentWorkspaceName,
@@ -46,6 +57,7 @@ export const WorkspaceBreadcrumb = ({
currentOrganizationId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: WorkspaceBreadcrumbProps) => {
const { t } = useTranslation();
@@ -57,6 +69,7 @@ export const WorkspaceBreadcrumb = ({
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current workspace name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
@@ -89,6 +102,59 @@ export const WorkspaceBreadcrumb = ({
}
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
const workspaceSettings = [
{
id: "general",
label: t("common.general"),
href: `${workspaceBasePath}/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `${workspaceBasePath}/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `${workspaceBasePath}/app-connection`,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `${workspaceBasePath}/feedback-sources`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `${workspaceBasePath}/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `${workspaceBasePath}/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `${workspaceBasePath}/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `${workspaceBasePath}/tags`,
},
{
id: "unify",
label: t("common.unify"),
href: `${workspaceBasePath}/workspace/unify`,
},
];
const areWorkspaceSettingsDisabled = isMembershipPending || isBilling;
const workspaceSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentWorkspace) {
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
logger.error(errorMessage);
@@ -97,13 +163,9 @@ export const WorkspaceBreadcrumb = ({
}
const handleWorkspaceChange = (workspaceId: string) => {
const targetPath =
workspaceId === currentWorkspaceId
? `/workspaces/${currentWorkspaceId}/surveys`
: `/workspaces/${workspaceId}/`;
if (workspaceId === currentWorkspaceId) return;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
router.push(`/workspaces/${workspaceId}/`);
});
};
@@ -115,9 +177,9 @@ export const WorkspaceBreadcrumb = ({
setOpenCreateWorkspaceModal(true);
};
const handleWorkspaceSettingsNavigation = (href: string) => {
const handleWorkspaceSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(href);
router.push(`${workspaceBasePath}/${settingId}`);
});
};
@@ -126,7 +188,7 @@ export const WorkspaceBreadcrumb = ({
return [
{
text: t("workspace.settings.billing.upgrade"),
href: `${workspaceBasePath}/settings/organization/billing`,
href: `${workspaceBasePath}/settings/billing`,
},
{
text: t("common.cancel"),
@@ -139,7 +201,7 @@ export const WorkspaceBreadcrumb = ({
{
text: t("workspace.settings.billing.upgrade"),
href: isLicenseActive
? `${workspaceBasePath}/settings/organization/enterprise`
? `${workspaceBasePath}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
@@ -230,15 +292,39 @@ export const WorkspaceBreadcrumb = ({
)}
</>
)}
<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>
<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>
{workspaceSettings.map((setting) => (
<div key={setting.id}>
{areWorkspaceSettingsDisabled ? (
<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">
{workspaceSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveWorkspaceSetting(pathname, setting.id)}
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
@@ -19,13 +19,13 @@ export const AccountSettingsNavbar = ({ activeId, loading }: AccountSettingsNavb
{
id: "profile",
label: t("common.profile"),
href: `${workspaceBasePath}/settings/account/profile`,
href: `${workspaceBasePath}/settings/profile`,
current: pathname?.includes("/profile"),
},
{
id: "notifications",
label: t("common.notifications"),
href: `${workspaceBasePath}/settings/account/notifications`,
href: `${workspaceBasePath}/settings/notifications`,
current: pathname?.includes("/notifications"),
},
];
@@ -0,0 +1,39 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganization } from "@/lib/organization/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [workspace, session] = await Promise.all([
getWorkspace(params.workspaceId),
getServerSession(authOptions),
]);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const organization = await getOrganization(workspace.organizationId);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
};
export default AccountSettingsLayout;
@@ -99,9 +99,7 @@ export const EditAlerts = ({
)}
<p className="pb-3 pl-4 text-xs text-slate-400">
{t("workspace.settings.notifications.want_to_loop_in_organization_mates")}{" "}
<Link
className="font-semibold"
href={`/workspaces/${currentWorkspace?.id}/settings/organization/general`}>
<Link className="font-semibold" href={`/workspaces/${currentWorkspace?.id}/settings/general`}>
{t("common.invite_them")}
</Link>
</p>
@@ -14,7 +14,7 @@ export const IntegrationsTip = () => {
<p className="text-sm">
{t("workspace.settings.notifications.need_slack_or_discord_notifications")}?
<a
href={`/workspaces/${workspace?.id}/settings/workspace/integrations`}
href={`/workspaces/${workspace?.id}/integrations`}
className="ml-1 cursor-pointer text-sm underline">
{t("workspace.settings.notifications.use_the_integration")}
</a>
@@ -2,6 +2,7 @@
import { useTranslation } from "react-i18next";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -17,7 +18,9 @@ const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.notifications")} />
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="notifications" loading />
</PageHeader>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
@@ -2,9 +2,10 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts";
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/IntegrationsTip";
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/types";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/components/EditAlerts";
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/components/IntegrationsTip";
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/types";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -127,7 +128,10 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships;
};
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => {
const Page = async (props: {
params: Promise<{ workspaceId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams;
const t = await getTranslate();
const session = await getServerSession(authOptions);
@@ -151,7 +155,9 @@ const Page = async (props: { searchParams: Promise<Record<string, string>> }) =>
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.notifications")} />
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="notifications" />
</PageHeader>
<SettingsCard
title={t("workspace.settings.notifications.email_alerts_surveys")}
description={t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses")}>
@@ -9,7 +9,7 @@ import {
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
} from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -8,7 +8,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/password-confirmation-modal";
import { PasswordConfirmationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
@@ -2,6 +2,7 @@
import { useTranslation } from "react-i18next";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -27,7 +28,9 @@ const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.profile")} />
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="profile" loading />
</PageHeader>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
@@ -1,7 +1,8 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSecurity } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/AccountSecurity";
import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/DeleteAccount";
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/EditProfileDetailsForm";
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/AccountSecurity";
import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/DeleteAccount";
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/EditProfileDetailsForm";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
@@ -33,7 +34,9 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.profile")} />
<PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="profile" />
</PageHeader>
{user && (
<div>
<SettingsCard
@@ -59,13 +62,13 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
? t("common.upgrade_plan")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/workspaces/${params.workspaceId}/settings/organization/billing`
? `/workspaces/${params.workspaceId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: IS_FORMBRICKS_CLOUD
? `/workspaces/${params.workspaceId}/settings/organization/billing`
? `/workspaces/${params.workspaceId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
@@ -0,0 +1,6 @@
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,3 +1,5 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/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";
@@ -6,7 +8,9 @@ const Loading = async () => {
const t = await getTranslate();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.enterprise_license")} />
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="billing" loading />
</PageHeader>
<div className="my-8 h-64 animate-pulse rounded-xl bg-slate-200"></div>
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
</PageContentWrapper>

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