Compare commits

..

353 Commits

Author SHA1 Message Date
Bhagya Amarasinghe c5d629ef25 feat(ai): support Vertex AI ADC credentials 2026-05-05 18:04:30 +05:30
Dhruwang Jariwala 94c9e8fcf1 feat: gate Unify Feedback, FRDs, Dashboards behind license (#7924) 2026-05-05 17:15:14 +05:30
pandeymangg 49a8c8c686 adds nav links 2026-05-05 16:33:35 +05:30
pandeymangg 2832831db1 chore: merge with epic/v5 2026-05-05 16:21:34 +05:30
pandeymangg b5e6567194 fixes 2026-05-05 16:13:12 +05:30
Dhruwang Jariwala 86d3f2fae1 chore: hardening cube tenant isolation (#7920) 2026-05-05 16:03:11 +05:30
pandeymangg 62d09f6a8f chore: merge with epic/v5 2026-05-05 15:14:53 +05:30
Johannes 74dd778630 feat: similar feedback preview (#7917)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-05 13:28:08 +04:00
Tiago Farto 7ac99c0840 chore: update 2026-05-05 08:50:03 +00:00
Tiago Farto dde0f8d32c Merge branch 'epic/v5' into chore/harden-cube-tenant-isolation 2026-05-05 08:49:36 +00:00
Tiago Farto bcd3c91075 chore: address PR concerns 2026-05-05 08:39:56 +00:00
Bhagya Amarasinghe f376c620ab docs: align self-hosting docs for Formbricks v5 (#7906) 2026-05-05 12:52:18 +05:30
Bhagya Amarasinghe 4865a78338 docs: align v5 cube deployment guidance 2026-05-05 12:50:41 +05:30
Bhagya Amarasinghe a7c8e1acf9 docs: add MinIO to RustFS migration pointer 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe e5a097e56e docs: address CodeRabbit review feedback 2026-05-05 12:47:02 +05:30
Bhagya Amarasinghe 1ddde9cac7 docs: align self-hosting docs for Formbricks v5 2026-05-05 12:47:02 +05:30
Anshuman Pandey 59f5cdfb4b fix: hub pinned at specific tag/digest (#7923) 2026-05-05 11:15:41 +04:00
pandeymangg 8431eaf9f6 chore: merge with epic/v5 2026-05-05 11:38:39 +05:30
Dhruwang Jariwala f228e8e06a chore: Rename FeedbackRecordDirectory to FeedbackDirectory (#7925) 2026-05-05 09:15:20 +04:00
Dhruwang Jariwala 5e6ab81cb1 fix: migrate feedback-sources page to unified settings navigation (#7928) 2026-05-05 10:09:30 +05:30
Tiago Farto 1417a5a654 chore: restore document 2026-05-04 13:05:53 +00:00
Tiago Farto f8ae92b3be chore: remove doc 2026-05-04 13:04:37 +00:00
Dhruwang 1bc3f79f30 fix: translations 2026-05-04 18:25:11 +05:30
Dhruwang 7151dd5234 fix: migrate feedback-sources page to unified settings navigation
The feedback-sources page was still using the old WorkspaceConfigNavigation
(secondary tabs) instead of the new unified settings sidebar introduced in
#7904. This caused an inconsistent navigation experience.

Changes:
- Create new route at /settings/workspace/feedback-sources
- Add feedback-sources entry to SettingsSidebarContent
- Remove old WorkspaceConfigNavigation from ConnectorsSection
- Redirect old /feedback-sources route to new settings path
- Update all stale /feedback-sources links across the codebase
2026-05-04 18:20:54 +05:30
Dhruwang Jariwala 086315ce33 feat: unify settings UI with shared sidebar navigation (#7904) 2026-05-04 17:37:53 +05:30
Tiago Farto e01b4311ca chore: cleaned documentation duplication 2026-05-04 11:52:41 +00:00
Tiago Farto dd757394af fix: make bundled cube optional 2026-05-04 11:03:37 +00:00
Dhruwang 507f80f9b0 fix: update stale settings routes to match new /settings/{organization,workspace}/ structure
All internal links (billing, enterprise, general, api-keys, feedback-record-directories,
integrations) now point to their correct nested paths under /settings/organization/ or
/settings/workspace/. Also adds feedback-record-directories to the new sidebar nav with
the member visibility rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 16:31:25 +05:30
pandeymangg 8562232280 adds tests 2026-05-04 13:27:22 +05:30
pandeymangg 1234e6685a fixes feedback 2026-05-04 13:15:37 +05:30
pandeymangg 40a5e8ea6a fixes tests and i18n validation 2026-05-04 13:12:38 +05:30
pandeymangg 319a76a70d moves connectors, dashboards and frd to ee 2026-05-04 12:55:34 +05:30
Tiago Farto 2abf8e1d8c fix: log rejected cube tenant queries 2026-04-30 16:52:41 +00:00
Tiago Farto a985dc698b refactor: simplify cube query filter traversal 2026-04-30 16:50:55 +00:00
Tiago Farto 7b59a6300e fix: address cube tenant isolation review 2026-04-30 16:34:09 +00:00
Tiago Farto bf8b4079fd test: isolate cube env config test 2026-04-30 16:20:29 +00:00
Tiago Farto 5704bfbc03 chore: hardening cube tenant isolation 2026-04-30 16:00:08 +00:00
Dhruwang 0920ccf2c3 fix: remove unused isBilling prop and stale translation keys
- Remove isBilling from WorkspaceBreadcrumb/WorkspaceAndOrgSwitch prop chain
- Remove unused common.organization_settings and common.unify translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:24:32 +05:30
Dhruwang db0c9e7c55 fix: update E2E action tests to wait for user-actions URL
The tests were waiting for a redirect to app-connection that no longer
exists — user-actions is now a standalone page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:09:45 +05:30
Dhruwang ef87d899b9 fix: simplify dropdown menus and fix Connect Your App icon
- Replace individual settings items in workspace/org dropdowns with single Settings link
- Change Connect Your App icon from ListChecksIcon to UnplugIcon
- Remove unused code (isActiveOrganizationSetting, isActiveWorkspaceSetting, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 18:06:19 +05:30
Bhagya Amarasinghe ea92ef9fce feat: add FeedbackRecords Envoy gateway (#7818) 2026-04-30 17:17:05 +05:30
pandeymangg 778fc2acf1 fix 2026-04-30 16:36:09 +05:30
Dhruwang 2ffef36c89 fix: update E2E tests for user-actions route and Teams heading ambiguity
- action.spec.ts: navigate to user-actions page instead of app-connection
- organization.spec.ts: use level:1 to disambiguate "Teams" heading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:30:22 +05:30
pandeymangg 1d6bda74df removes route test 2026-04-30 16:26:47 +05:30
pandeymangg 12ff0b7c0e sonar issue fix 2026-04-30 16:19:11 +05:30
Dhruwang fa1079bac1 fix: update E2E tests for renamed settings labels
- "Look & Feel" comments → "Appearance"
- "Members & Teams" heading assertion → "Teams"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:07:18 +05:30
Dhruwang 1403f0bb01 fix: pass missing isBilling prop to WorkspaceBreadcrumb
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:06:34 +05:30
Dhruwang c79553633f fix: use export...from to re-export default in user-actions routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 16:04:04 +05:30
Bhagya Amarasinghe f16fb3b62f fix: restore required feedback record list params 2026-04-30 15:59:45 +05:30
Dhruwang 7dfc7f4825 docs: update references to renamed settings labels
- "Configuration" → "Settings"
- "Look & Feel" → "Appearance"
- "Website & App Connection" → "Connect Your App" / "User Actions"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:46:47 +05:30
Dhruwang 1ecc9f1722 fix: restore settings sidebar, rename labels, fix SonarQube issues, and extract user-actions page
- Restore settings sidebar in MainNavigation (lost during epic/v5 merge)
- Rename "Configuration" to "Settings", "Look & Feel" to "Appearance", etc.
- Fix SonarQube issues: duplicate class, regex injection, nested ternary, inline arrow functions
- Extract User Actions from Connect Your App into its own settings page
- Update all i18n translation keys across locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 15:41:50 +05:30
Dhruwang 7d1c02b54b Merge remote-tracking branch 'origin/epic/v5' into feat/settings-cleanup-v5 2026-04-30 14:56:35 +05:30
Bhagya Amarasinghe f2c452d7f9 feat: make cubejs mandatory for xm suite v5 (#7913) 2026-04-30 14:34:50 +05:30
Bhagya Amarasinghe afcfbb7a3a fix: address cube review follow-ups 2026-04-30 14:17:54 +05:30
Bhagya Amarasinghe 7f8c9dcbb8 chore: merge epic/v5 into feedback records gateway 2026-04-30 01:22:24 +05:30
Bhagya Amarasinghe 3998e4da31 fix: resolve sonar quality gate warning 2026-04-30 00:59:25 +05:30
Bhagya Amarasinghe 48086faffc fix: address cube review feedback 2026-04-30 00:39:49 +05:30
Bhagya Amarasinghe 38a0d7c810 Merge remote-tracking branch 'origin/epic/v5' into bhagya/eng-765-make-cubejs-mandatory-for-xm-suite-v5 2026-04-30 00:32:05 +05:30
Bhagya Amarasinghe b17bb88daa fix: require cube env vars in app config 2026-04-30 00:30:11 +05:30
Anshuman Pandey f59e9f13ec feat: refresh analysis charts and dashboard feedback gating (#7915)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-29 16:29:14 +04:00
Anshuman Pandey 5169dec510 feat: wire workspace settings to feedback record directories (#7910)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:49:50 +04:00
Bhagya Amarasinghe 0df16f6f0c feat: make cubejs mandatory for xm suite v5 2026-04-29 16:08:24 +05:30
Anshuman Pandey 8442dedf9c fix: removes project references (#7907) 2026-04-29 14:17:42 +04:00
Dhruwang 22c27c5ebb fix: remove unused params prop from notifications page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:27:50 +05:30
Dhruwang 6638dceb04 feat: disable settings for billing role, hide back button, add org switcher to landing sidebar
- Disable all workspace and select org settings items for billing-role users
- Hide the top bar (back button) for billing users in settings mode
- Add organization switcher with lazy-loaded org list to landing sidebar
- Pass isMultiOrgEnabled to landing sidebar for create-org option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:24:42 +05:30
Dhruwang 8558121e46 feat: enhance SettingsSidebarContent with tooltip and popover for disabled items
- Added Tooltip and Popover components to provide user feedback for disabled navigation items.
- Implemented conditional rendering of tooltips and popovers based on item state.
- Introduced a disabledMessage prop to display appropriate messages for unauthorized actions.
2026-04-29 14:46:48 +05:30
Dhruwang f1279d51e5 fix: transaltions 2026-04-29 14:42:48 +05:30
Dhruwang 926706be9d fix: merge epic/v5, fix stale integration URLs and settings workspace switcher
- Resolve merge conflict in create-connector-modal (keep NoFeedbackRecordDirectoryAlert)
- Fix GoBackButton URLs in slack, google-sheets, airtable integration pages to use /settings/workspace/integrations path
- Fix connectHref values in integrations page (webhooks, google-sheets, airtable, slack, notion, JS SDK)
- Fix handleWorkspaceChange to stay in settings mode when switching workspace from settings sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 14:36:06 +05:30
Dhruwang 85b456e619 fix: navigate to surveys via URL in multi-language e2e test
The settings sidebar replaces the main nav, so the "Surveys" link is
not visible when on a settings page. Use direct URL navigation instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 13:46:34 +05:30
Bhagya Amarasinghe 3bac488a29 fix: address gateway review follow-ups 2026-04-29 12:27:41 +05:30
Johannes fbe2a31133 refactor: align connector enum with formbricks_survey (#7825)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-29 10:57:20 +04:00
Bhagya Amarasinghe 79d618f77c refactor: generalize gateway token minting 2026-04-29 12:16:08 +05:30
Anshuman Pandey 89eb04f813 fix: adds submission id to csv connector (#7898) 2026-04-29 10:36:16 +04:00
Dhruwang 8a2b349329 feat: unify settings under /workspaces/[id]/settings with shared sidebar navigation
Consolidate all settings (Account, Organization, Workspace) under a unified
URL structure with a shared sidebar that replaces the main navigation when
in settings mode. Remove horizontal nav bars, old dropdown-based navigation
patterns, and route group layouts in favor of real URL segments.

- Move workspace settings from /(workspace)/ to /settings/workspace/
- Move org settings from /settings/(organization)/ to /settings/organization/
- Move account settings from /settings/(account)/ to /settings/account/
- Add SettingsSidebarContent with inline workspace/org switchers
- Replace main sidebar with settings nav when pathname includes /settings
- Update all page headings to match sidebar nav labels
- Update e2e tests for new URL structure and navigation patterns
- Remove unused translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 18:26:58 +05:30
Dhruwang Jariwala a862b739f7 fix: consistent enabled/disabled wording for connector status (#7897) 2026-04-28 15:11:44 +05:30
Dhruwang 4e5df85538 fix: make pipeline dispatch fire-and-forget in management responses route
Pipeline errors (snapshot loading or dispatch) should not prevent the
201 response from being returned. Dispatch pipeline events without
awaiting so the response is returned immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 14:31:24 +05:30
Dhruwang 727b349086 fix: resolve pre-existing build errors on epic/v5
- Add optional chaining for organization.billing in response pipeline
- Add missing feedbackRecordDirectoryId to Chart seed data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 13:47:20 +05:30
Dhruwang f75db6b1d0 fix: translations 2026-04-28 12:39:12 +05:30
Dhruwang 7ffca53577 fix: use consistent enabled/disabled wording for connector status badges
The dropdown actions say "Enable"/"Disable" but the status badges showed
"In Progress"/"Paused". Now both use "Enabled"/"Disabled" for consistency.

Resolves ENG-769

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:24:49 +05:30
Dhruwang Jariwala 25614b23fc chore: remove legacy styling fields (questionColor, inputColor) (#7783) 2026-04-28 11:22:03 +05:30
Johannes 016e14d0f1 fix: (Depr Env QA) Surface legacy env var on Connection page (#7773) 2026-04-27 14:20:25 +00:00
Bhagya Amarasinghe be80db8418 fix: address envoy auth review findings 2026-04-27 19:31:43 +05:30
Bhagya Amarasinghe bcc3789ce8 refactor: generalize envoy auth dispatch 2026-04-27 18:31:58 +05:30
Dhruwang 5e76ebdfc1 fix: treat JSON null as absent in legacy styling migration
The `?` operator only checks key existence — if the form layer saved
`{"elementHeadlineColor": null}` (JSON null = "use default"), the
migration skipped the copy and then removed the legacy key, losing
the color value. Switch to COALESCE(styling->'field', 'null'::jsonb)
= 'null'::jsonb which catches both missing keys (SQL NULL) and JSON
null values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 17:07:05 +05:30
Bhagya Amarasinghe 150f256721 fix: decouple pipeline from response ingestion (#7651) 2026-04-27 12:59:27 +05:30
pandeymangg da7971328c little cleanup 2026-04-27 12:58:42 +05:30
Bhagya Amarasinghe a6cd56b196 fix: migrate response pipeline to BullMQ (#7651) 2026-04-27 12:58:42 +05:30
pandeymangg 7c81cf119e adds test for schedulePipelineDrain when env vars are not set 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 8d29b24352 fix: address latest pipeline review comments (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe a1ae849496 fix: address CodeRabbit pipeline findings (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 4d0a686e89 fix: address pipeline PR checks (#7651) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe 364915e4c8 fix: decouple pipeline from response ingestion (#1487) 2026-04-27 12:56:22 +05:30
Bhagya Amarasinghe ada2518d0c fix: address feedback records gateway build failures 2026-04-24 17:30:41 +05:30
Bhagya Amarasinghe 57d1c0ed99 fix: resolve feedback records PR check failures 2026-04-24 16:53:57 +05:30
Tiago 817b299436 chore: rename gcp ai provider to google (#7815) 2026-04-24 10:10:58 +00:00
Tiago Farto c140dae872 Merge branch 'epic/v5' into chore/rename_google 2026-04-24 09:51:39 +00:00
Bhagya Amarasinghe 6036a8c767 fix: harden FeedbackRecords Envoy auth routing 2026-04-24 13:51:54 +05:30
Dhruwang Jariwala bf592937f4 feat: AI-powered survey translation (#7793) 2026-04-24 12:55:36 +05:30
Bhagya Amarasinghe 1cfadd968a feat: add FeedbackRecords Envoy gateway 2026-04-24 02:17:54 +05:30
Tiago Farto 21ed383a46 chore: address PR concerns 2026-04-23 13:45:09 +00:00
Bhagya Amarasinghe 7ed7101ac1 feat: adds feedback record directory auth to api keys (#7804) 2026-04-23 18:04:17 +05:30
Tiago Farto 7aa12a4f0c chore: rename google ai things 2026-04-23 12:27:39 +00:00
pandeymangg 2e926936fb addressed feedback 2026-04-23 17:46:09 +05:30
Dhruwang 8edef8aede refactor: replace repeated union type with TDimension alias in TBaseStyling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:22:13 +05:30
Dhruwang 54fb202285 fix: add jsonb_typeof guard to legacy styling migration
Ensures the UPDATE only processes JSONB objects, preventing errors
on unexpected scalar or array values in the styling column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:16:50 +05:30
Dhruwang c720a462a7 fix: rename inputColor to inputTextColor in survey-ui storybook files
Aligns storybook story helpers and element stories with the legacy
field removal — inputColor → inputTextColor, mapping to
--fb-input-text-color CSS variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 16:12:53 +05:30
Dhruwang a386451e6e refactor: consolidate imports in actions.ts
Merged the import statements for ZAITranslationField and translateFields from the translate-fields module to streamline the code structure.
2026-04-23 15:17:31 +05:30
Dhruwang f0bf111e7b fix: test 2026-04-23 15:08:18 +05:30
Dhruwang 8a57a5b74b addressed feedback 2026-04-23 15:05:16 +05:30
Dhruwang 434cb1d0d0 refactor: remove BullMQ background jobs from AI translation
Replace the async job queue + Redis polling pattern with a direct
server action call. The translation now runs synchronously inside
translateSurveyFieldsAction, removing the need for BullMQ job
definitions, processors, cache keys, and client-side polling logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:11:28 +05:30
pandeymangg 8bde75a9ff chore: merge with epic/v5 2026-04-23 11:06:54 +05:30
pandeymangg 6b880f29cb chore: merge with epic/v5 2026-04-23 11:00:25 +05:30
Dhruwang Jariwala 969c9834e5 fix: (Depr Env QA) update lang keys (#7786) 2026-04-23 10:57:47 +05:30
pandeymangg 5e33b7c9a4 fixes and e2e fixes 2026-04-23 10:55:20 +05:30
Dhruwang 230ea744fa fix: simplify workspace permissions assignment in ViewPermissionModal 2026-04-23 10:38:18 +05:30
Anshuman Pandey fae1fb8f96 fix: cleans up environmentId references (#7792) 2026-04-23 09:05:00 +04:00
Dhruwang eac35daed9 fix: transllations 2026-04-23 10:34:29 +05:30
Johannes 45accc1edb fix: align workspace naming in setup and email preview
Replace remaining environment wording with workspace terminology across setup flows, API key permissions, and email preview text, and switch the email Tailwind config to ESM so formatting hooks run under the current Node runtime.

Made-with: Cursor
2026-04-23 10:30:30 +05:30
Dhruwang Jariwala 02ebe8e9f8 fix: (Depr Env QA) update docs (#7784) 2026-04-23 10:23:23 +05:30
pandeymangg cae859e326 sonarcloud fixes 2026-04-22 17:15:19 +05:30
pandeymangg 5352d563b6 fixes and consistency 2026-04-22 17:03:18 +05:30
Anshuman Pandey 711f2bfe67 chore: restores feedback record directory changes to epic/v5 (#7806) 2026-04-22 15:22:15 +04:00
Dhruwang 6fcb5d39a2 fix: address code review feedback for AI translation
- Reuse ZAITranslationField from @formbricks/jobs instead of duplicating
  the schema locally in actions.ts; tighten sourceLanguage/targetLanguage
  validators with .min(1) to match the downstream job schema

- Guard against undefined translations in getAITranslationResultAction
  instead of using the unsafe `translations!` assertion — return
  "pending" status for malformed cache entries

- Use createCacheKey.custom("ai-translation", jobId) instead of raw
  template strings to follow cache key conventions

- Improve JSON parsing robustness: strip markdown code fences before
  attempting JSON.parse, log raw response on parse failures

- Add stale-request guard and error handling to the AI availability
  useEffect in language-view.tsx

- Replace shared pollingCancelledRef boolean with per-invocation Symbol
  token to prevent stale polling loops from clobbering state when the
  modal is reopened

- Track timeout explicitly with a timedOut flag so the "timed out" toast
  doesn't fire when polling was actually cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:01:57 +05:30
pandeymangg 1ed9859ee7 fixes tests 2026-04-22 15:58:02 +05:30
pandeymangg cd72a0a78d adds translations 2026-04-22 15:44:45 +05:30
pandeymangg 2b09795787 feat: adds frd auth to api keys 2026-04-22 15:43:33 +05:30
Dhruwang 2451acb9bd fix: AI translation security, error handling, and test coverage
- Add userId verification in getAITranslationResultAction (security)
- Use OperationNotAllowedError for auth failures
- Store failure marker in cache on last BullMQ attempt
- Make JSON parsing more robust (extract first {...} block)
- Add "keep modal open" hint to translating toast
- Add test coverage for process-ai-translation-job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:18:54 +05:30
Dhruwang 14dcded91b Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into feat/ai-survey-translation 2026-04-22 15:01:12 +05:30
Dhruwang 46062f91cd refactors 2026-04-22 14:50:46 +05:30
Dhruwang Jariwala ffd4478184 chore: merge epic/dashboards into epic/v5 (#7798)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:05 +04:00
Dhruwang 69da1862fa Merge latest epic/v5 (survey scheduling) into ai-translation branch
Resolve merge conflicts to combine AI translation and survey scheduling
features in jobs package, instrumentation, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 13:39:10 +05:30
Dhruwang c11d3241ab fix: test and translations 2026-04-22 12:59:27 +05:30
Tiago 3fb09a1a26 feat: survey scheduling (#7766) 2026-04-21 15:00:51 +00:00
Dhruwang 6efa449c10 feat: add AI-powered survey translation via BullMQ background jobs
Add a "Translate with AI" button to the Manage Translations modal that
auto-populates empty translation fields using the configured AI provider.
Translation runs as a BullMQ background job with results cached in Redis
and polled by the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:38:47 +05:30
Dhruwang 34b94689ca fix: address CodeRabbit review feedback on docs
- Fix broken API URLs in docs-feedback.mdx (remove /workspaces segment, fix https://, remove stray };)
- Add missing workspaceId path params to v2 spec (responses, displays, user)
- Remove environmentId from required arrays in v2 request schemas
- Fix stale terminology: environment→workspace in database-model, tenant-separation, tags, actions
- Fix broken link to removed test-environment page in webhooks.mdx
- Fix redundant "codebase" in naming-conventions description
- Use neutral hostname in audit-logging example
- Hyphenate "open-source" in license.mdx
- Consistent workspaceId formatting in wordpress.mdx
- Update link text to match anchor in actions.mdx
- Remove dual environmentId/workspaceId in headless-surveys example
- Fix stale <project_id> placeholder in headless-surveys
- Fix awkward Next.js card copy in framework-guides.mdx
- Clarify BC wording in v2 introduction.mdx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:35:26 +05:30
Tiago Farto 901fac7e08 chore: fix env vars 2026-04-21 09:56:32 +00:00
Dhruwang 739c662863 chore: merge epic/v5 and resolve openapi.json conflicts
Accept epic/v5 removals (attribute-classes, people endpoints) and
re-apply workspaceId rename + deprecation notes + endpoint cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:22:17 +05:30
Dhruwang 535974ff8a fix: align API specs with actual routes and add environment deprecation notes
- Restore v1 openapi.json from main with environmentId→workspaceId rename
- Remove 5 non-existent v1 endpoints and their orphaned MDX pages
- Update v1 descriptions from "environment" to "workspace" terminology
- Add environment deprecation notes to all v1 client API endpoints
- Remove 2 non-existent v2 client endpoints (contacts attributes, identify)
- Rename v2 project-teams → workspace-teams (path, operationIds, schema)
- Preserve environment deprecation notes in v2 spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:15:52 +05:30
Tiago Farto a8b97abe9a Merge remote-tracking branch 'origin/epic/v5' into feat/survey-scheduling
# Conflicts:
#	pnpm-lock.yaml
2026-04-21 09:37:41 +00:00
Johannes 28103604b4 fix: (Depr Env QA) api v1/me regression (#7761)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-21 13:26:01 +04:00
Tiago Farto b5a7e15386 chore: fix build 2026-04-21 09:07:45 +00:00
Tiago Farto fec4746d5d chore: address PR comments 2026-04-21 08:51:32 +00:00
pandeymangg 175323e7d9 chore: merge with main 2026-04-21 14:07:14 +05:30
Tiago Farto 6130737d51 chore: fix DST bug 2026-04-20 16:57:48 +00:00
Tiago Farto bf10a8d0b2 Merge branch 'epic/v5' into feat/survey-scheduling 2026-04-20 16:09:16 +00:00
Tiago Farto 612f8dceb8 chore: fix test 2026-04-20 15:50:38 +00:00
Tiago 0303f16db4 feat: BullMQ background jobs + response pipeline (#7779) 2026-04-20 15:30:20 +00:00
Tiago Farto 07635b160e chore: fix test; migration 2026-04-20 15:13:27 +00:00
Tiago Farto 9cfcffdb5e chore: bug fix; tests 2026-04-20 14:22:06 +00:00
Tiago Farto 02264ffc5f chore: build fix 2026-04-20 13:25:01 +00:00
Serhat e489c6a346 feat: Add Turkish (tr) translations (#7645)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-20 12:51:25 +00:00
Tiago Farto 7dde3edd8d chore: fix tests 2026-04-20 12:30:03 +00:00
Dhruwang 730ab6a609 fix: use valid hex colors in styles unit tests
Replace invalid fake hex values (e.g. "#btn-bg", "#headline-color") with
valid hex colors so isLight() and mixColor() don't throw. Add missing
inputTextColor to the survey styling test so --fb-placeholder-color is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:41:19 +05:30
Johannes 4304a7efd6 Rename Projects to Workspaces in docs 2026-04-20 13:56:27 +02:00
Tiago Farto a89d598f8d Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:46:13 +00:00
Tiago Farto 6ff5af712f chore: clean tests 2026-04-20 11:35:47 +00:00
Anshuman Pandey 398ba79e7e feat: ces and csat questions (#7688)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-20 15:33:26 +04:00
Dhruwang 4e75a57692 chore: remove legacy styling fields (questionColor, inputColor) for v5
Add a SQL migration that copies legacy coarse-grained styling fields
to granular equivalents (e.g. questionColor → elementHeadlineColor,
inputColor → inputBgColor) and strips the legacy keys from the JSONB.

Remove the runtime deriveNewFieldsFromLegacy() shim, all fallback
chains in CSS variable generation, and update types, schemas, tests,
and OpenAPI spec to reflect the new canonical field names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:00:20 +05:30
Tiago Farto 5127de9de0 chore: revert CI action 2026-04-20 11:11:09 +00:00
Tiago Farto 2bf7788a1b Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:07:33 +00:00
Tiago Farto ee8122778b chore: address PR comments 2026-04-20 10:43:32 +00:00
Tiago Farto 8aaa7ed9c0 chore: build fix 2026-04-20 10:00:06 +00:00
Johannes bc7c8c5715 remove environment ID andenv references 2026-04-20 11:40:33 +02:00
Dhruwang Jariwala ab1ea7a5ce fix: remove legacy API rewrites from next.config.mjs (#7764)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-20 13:38:30 +04:00
Tiago Farto 4f749355e0 chore: fix coverage test 2026-04-20 09:14:06 +00:00
Tiago Farto 18b60ddd35 chore: fix build 2026-04-20 08:52:59 +00:00
Tiago Farto 87f1b01c7a chore: fix broken tests 2026-04-20 08:40:44 +00:00
Tiago Farto 851ea0deb2 chore: fix broken lock 2026-04-20 08:32:45 +00:00
pandeymangg 9abbbfdd35 chore: merge with main 2026-04-20 13:07:36 +05:30
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Johannes 990c0eee31 refined UX 2026-04-19 16:05:29 +02:00
Tiago Farto 07f16b8a43 chore: fix build 2026-04-17 23:23:51 +00:00
Tiago Farto bf556b0608 chore: fix linting 2026-04-17 22:26:57 +00:00
Tiago Farto 8b0766a46e chore: bix fuild 2026-04-17 22:16:17 +00:00
Tiago Farto 1f995d6e25 chore: build fix 2026-04-17 20:05:11 +00:00
Tiago Farto 975a4d57f8 chore: fix build 2026-04-17 19:50:23 +00:00
Tiago Farto 69bd576fc5 chore: fix build 2026-04-17 16:37:22 +00:00
Tiago Farto a2e4a3bbd7 chore: fix build 2026-04-17 16:27:18 +00:00
Tiago Farto 281f854332 chore: address PR comments 2026-04-17 15:36:12 +00:00
Tiago Farto 24496774a5 chore: fix build 2026-04-17 14:57:55 +00:00
Tiago Farto aeaf3215b4 chore: fix 2026-04-17 14:51:51 +00:00
Tiago Farto f4c5162590 Merge epic/bullmq into feat/survey-scheduling 2026-04-17 14:47:05 +00:00
Tiago Farto dedb7389f0 Merge origin/epic/v5 into epic/bullmq 2026-04-17 14:33:21 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Tiago Farto 7aed1b84de chore: translations, fixes 2026-04-17 11:59:17 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
Dhruwang Jariwala 60bd5cbeff fix: prevent environment ID leak in API error responses (#7753)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:38:32 +00:00
Dhruwang Jariwala b6a3a15379 fix: make other option input field mandatory when sole selection (#7751)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:06:00 +00:00
Johannes c68f214eff fix: keep sidebar switcher icons round with long labels (#7756)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 08:04:10 +00:00
Harsh Bhat c90ee84483 chore: Add survey to formbricks docs (#7746) 2026-04-16 12:13:55 +00:00
Dhruwang Jariwala dc1ee72594 chore: translation management revamp (scope 1) (#7733)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-16 11:18:48 +00:00
Dhruwang Jariwala 924132287e fix: connect rating/NPS scale labels to label styling settings (#7738)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:59:59 +00:00
Dhruwang Jariwala e6f347aa07 fix: remove dark: variant classes from survey-ui to prevent host page style leakage (#7747) 2026-04-16 05:50:46 +00:00
Dhruwang Jariwala 367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
Marius 0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
pandeymangg 3da7129413 fixes tests 2026-04-14 17:09:13 +05:30
pandeymangg 75fbb23190 chore: merge with main 2026-04-14 17:01:17 +05:30
Dhruwang Jariwala 439dd0b44e fix: add loading skeleton for responses page (#7700)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-13 16:56:20 +00:00
Anshuman Pandey 2556f5e15d fix: add missing PostHog events (#7722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:57:12 +00:00
Tiago Farto d361c334d3 chore: fixed management snapshot gap 2026-04-13 14:28:31 +03:00
Johannes cc0eec3bf0 feat: add auto-progress mode for rating and NPS surveys (#7709)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-13 11:22:50 +00:00
Tiago Farto a4d808b479 chore: build fix 2026-04-13 13:10:33 +03:00
Tiago Farto 18ae1748d3 chore: address PR comments 2026-04-13 12:50:21 +03:00
Johannes 4b009a8eb4 revert: enhance welcome card to support video uploads (#7712)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 08:17:05 +00:00
Johannes 2aaddf7306 fix: prevent TTC overcount for multi-question blocks (#7713)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 07:56:40 +00:00
Dhruwang Jariwala fb5d6145d0 fix: only show beforeunload warning when offline support is active (#7715) 2026-04-13 07:19:57 +00:00
Dhruwang Jariwala 59310bac93 fix: validate "Other" option text on required questions and remove duplicate response entry (#7716) 2026-04-13 07:05:08 +00:00
Dhruwang Jariwala 322f0be197 fix: improve restricted ID validation toast with i18n support (#7703)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-12 06:18:13 +00:00
Manuel Delgado 1a02f91afd fix(api): return 409 Conflict instead of 500 when creating user with duplicate email (#7675)
Co-authored-by: Tiago Farto <tiago@formbricks.com>
2026-04-10 14:28:17 +00:00
Tiago cc22ccb22d chore: Harden SSO account linking for existing email-based accounts (#7702) 2026-04-10 14:19:21 +00:00
Tiago 12763f0ef6 fix: Dutch translations for link survey footer (Privacy Policy, Imprint, Report Survey) (#7707) 2026-04-10 13:42:15 +00:00
Dhruwang Jariwala d39e3ee638 feat: offline support for link surveys (#7694)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-10 11:27:48 +00:00
dingdyan d85242a86b fix: handle internal server error toast behavior in create organization (#7662)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-10 11:13:10 +00:00
Bhagya Amarasinghe ef53065abc feat: support GKE Envoy ingress split with numeric ports and service annotations (#7704) 2026-04-10 09:22:19 +00:00
Dhruwang Jariwala 805c1c6874 fix: (duplicate) server error toast handling (#7701) 2026-04-10 09:22:16 +00:00
Niels Kaspers 01687e8907 fix: add TERMS_URL support to survey link footers (#7670) 2026-04-10 09:21:11 +00:00
Dhruwang Jariwala 60f6ca9463 chore: deprecate environments (#7693)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-10 09:13:47 +04:00
Johannes 31d455002d feat: unifiy nav auth behaviour (#7635)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-09 14:26:14 +00:00
Tiago Farto 3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto 83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto 2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto 54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto 1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto 197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
pandeymangg aa27d242bb chore: merge with main 2026-04-09 15:26:30 +05:30
Tiago 7ca52a7a93 feat: Introduce BullMQ setup to Formbricks (#7684) 2026-04-09 11:47:58 +03:00
Tiago Farto 4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto 92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Johannes d96304d86d fix: make navigation more user-friendly (#7599)
Co-authored-by: Tiago Farto <tiago@formbricks.com>
2026-04-09 08:03:24 +00:00
Tiago Farto ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Bhagya Amarasinghe 1064f68435 fix: support OTEL host config for envoy telemetry (#7692) 2026-04-09 07:25:52 +00:00
Tiago Farto 8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Anshuman Pandey 3d16e859c6 feat: custom posthog events (#7647) 2026-04-09 05:34:01 +00:00
Tiago Farto 03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Salim B af198c5632 docs: remove spurious left-overs (#7690) 2026-04-08 16:11:30 +00:00
Tiago Farto e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto 581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto 5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Bhagya Amarasinghe a43ed2b25c feat: add envoy gateway helm bundle (#7686) 2026-04-08 07:34:47 +00:00
Tiago Farto ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto 597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto 3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto 84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto 784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto 5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto 65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto 22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto 9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto 29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
Tiago 87bcad2b20 feat: Supporting different AI providers within Formbricks (#7611)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-06 05:45:12 +00:00
Anshuman Pandey b5eaa4c7fd fix: merge epic/improve-telemetry into main (#7666) 2026-04-03 10:12:51 +00:00
Tiago 995c03bc01 chore: Revoke all active sessions after password reset (#7628) 2026-04-03 06:10:28 +00:00
Johannes b4395a48c5 fix: multi-lang toggle covering arabic text (#7657)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-02 13:09:16 +00:00
Johannes 461e3893fe fix: 7549 multilang button overflow (#7656)
Co-authored-by: Niels Kaspers <kaspersniels@gmail.com>
2026-04-02 12:53:57 +00:00
Tiago 735a9f84ec fix: harden api error reporting for v2/v1 Sentry observability (#7633) 2026-04-02 12:08:44 +00:00
Dhruwang Jariwala 8cb8d734cf fix: prevent language switch from breaking survey orientation and resetting language on auto-save (#7654)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 12:08:12 +00:00
Anshuman Pandey 44d5530b48 fix: adds formbricks instance on window (#7630) 2026-04-02 07:26:48 +00:00
Matti Nannt a314eb391e chore: add Codex environment config (#7589) 2026-04-02 07:24:02 +00:00
Matti Nannt 6c34c316d0 docs: remove non-official self-hosting options from README.md 2026-04-01 14:16:47 +02:00
Matti Nannt 4f26278f16 docs: add German README summary (#7641) 2026-04-01 11:04:15 +02:00
Tiago b975e7fa2e feat: Make password reset links single-use and revocable (#7627) 2026-04-01 07:12:37 +00:00
Johannes 6c3052f9e4 fix: correct CSAT template option order for question 2 (#7636)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-01 07:11:27 +00:00
Dhruwang Jariwala a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Dhruwang Jariwala 5bb8119ebf feat: split AI toggle into smart tools and data analysis settings (#7563) 2026-03-31 11:23:51 +00:00
Johannes 02411277d4 revert: remove fake-door workflows experiment (#7392) (#7631)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-03-31 10:47:33 +00:00
Dhruwang Jariwala 4cfb8c6d7b fix: resolve language code case mismatch in link survey rendering (#7624)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:34:20 +00:00
Anshuman Pandey e74a51a5ff fix: sync segment state after auto-save to prevent stale reference on publish (#7619) 2026-03-30 06:51:44 +00:00
Dhruwang Jariwala 29cc6a10fe fix: prevent auto-save from overwriting survey status during publish (#7618) 2026-03-30 06:34:20 +00:00
Anshuman Pandey 029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Bhagya Amarasinghe 01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey 9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR 697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
Dhruwang Jariwala 83bc272ed2 fix: prevent duplicate hobby subscriptions from race condition (#7597)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:50:52 +00:00
Dhruwang Jariwala 59cc9c564e fix: duplicate org creation (#7593) 2026-03-26 05:52:09 +00:00
Dhruwang Jariwala 20dc147682 fix: scrolling behaviour to invalid questions (#7573) 2026-03-25 13:35:51 +00:00
cursor[bot] 2bb7a6f277 fix: prevent TypeError when checking for duplicate matrix labels (#7579)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-25 13:14:18 +00:00
Matti Nannt 81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
Dhruwang Jariwala deb062dd03 fix: handle 404 race condition in Stripe webhook reconciliation (#7584) 2026-03-25 09:58:00 +00:00
Dhruwang Jariwala 474be86d33 fix: translations for option types (#7576) 2026-03-24 13:18:26 +00:00
Dhruwang Jariwala e7ca66ed77 fix: use TTC data for reliable survey impression counting (#7572) 2026-03-24 08:52:35 +00:00
Matti Nannt 2b49dbecd3 chore: add dev:setup script to generate .env and missing secrets (#7555)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 08:26:32 +00:00
Anshuman Pandey 6da4c6f352 fix: proper errors server side when resources are not found (#7571) 2026-03-24 07:52:37 +00:00
Aryan Ghugare 659b240fca feat: enhance welcome card to support video uploads and display #7491 (#7497)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 07:34:43 +00:00
Dhruwang Jariwala 19c0b1d14d fix: response table settings formatting (#7540)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 06:36:45 +00:00
Dhruwang Jariwala b4472f48e9 fix: (Duplicate) prevent multi-language survey buttons from falling back to English (#7559) 2026-03-24 05:45:47 +00:00
bharath kumar d197271771 fix(web): add <noscript> message for when JS is disabled (#7455) (#7459)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 12:35:29 +00:00
Dhruwang Jariwala 37f652c70e fix: prevent session expiry during active use (#7558) 2026-03-23 10:44:55 +00:00
Matti Nannt 645f0ab0d1 fix: resolve remaining dependabot alerts (#7561) 2026-03-23 09:59:01 +00:00
Johannes 389a7d9e7b feat: enhance segment activity summary and settings in segment modal (#7553)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-23 08:39:10 +00:00
Tiago c4cf468c7e fix: localize survey and app date rendering (#7473)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 07:23:07 +00:00
Johannes cbc3e923e4 fix: segment targeting "isNotIn" didnt work (#7550) 2026-03-23 05:22:19 +00:00
Tiago a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt 998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr 13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala 0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey 0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago 78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala 95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala 136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey 0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr 540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala 2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Dhruwang Jariwala 633bf18204 fix: auto-expand multi-language card when toggle is enabled (#7504)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:18:35 +00:00
Balázs Úr 9a6cbd05b6 fix: mark various strings as translatable (#7338)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 11:30:38 +00:00
Johannes 94b0248075 fix: only allow URL in exact match URL (#7505)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 07:20:14 +00:00
Johannes 082de1042d feat: add validation for custom survey closed message heading (#7502) 2026-03-18 06:40:57 +00:00
Johannes 8c19587baa fix: ensure at least one filter is required for segments (#7503)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:39:58 +00:00
Anshuman Pandey 433750d3fe fix: removes pino pretty from edge runtime (#7510) 2026-03-18 06:32:55 +00:00
Johannes 61befd5ffd feat: add enterprise license features table (#7492)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:14:40 +00:00
Dhruwang Jariwala 1e7817fb69 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-17 15:33:44 +00:00
Anshuman Pandey f250bc7e88 fix: fixes race between setUserId and trigger (#7498)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 08:57:07 +00:00
Santosh c7faa29437 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7409)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 05:36:58 +00:00
Anshuman Pandey a51a006c26 fix: fixes data element i18n fixes (#7488) 2026-03-16 10:12:48 +00:00
Matti Nannt ce96cb0b89 feat: replace hosted stripe pricing table (#7486)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-16 10:11:40 +00:00
Matti Nannt fb265d9dba feat: add SAML telemetry reporting (#7461) 2026-03-16 09:41:33 +00:00
Matti Nannt e4c155b501 fix: defer hobby subscription creation (#7484) 2026-03-15 14:13:53 +00:00
Johannes 2dc5c50f4d feat: implement trial days remaining alert in billing components (#7474) 2026-03-13 16:38:43 +01:00
Anshuman Pandey bddcec0466 fix: adds monkey patching for replaceState (#7475) 2026-03-13 13:40:20 +00:00
Dhruwang Jariwala 92677e1ec0 fix: respect overwriteThemeStyling in link survey metadata (#7466)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-13 13:07:54 +00:00
Anshuman Pandey b12228e305 fix: fixes button url fixes in survey editor (#7472) 2026-03-13 13:07:41 +00:00
Dhruwang Jariwala 91be2af30b fix: add missing Stripe billing setup for setup route org creation (#7470)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:18:01 +01:00
Anshuman Pandey 84c668be86 fix: fixes contact links api gating issue (#7468) 2026-03-13 11:09:53 +00:00
Dhruwang Jariwala 4015c76f2b fix: use logical CSS direction classes for RTL matrix question (#7463)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:06:41 +00:00
Dhruwang Jariwala a7b2ade4a9 fix: remove follow-ups from trial features and gate trial page for subscribers (#7465)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:00:23 +00:00
Dhruwang Jariwala 75f44952c7 fix: clear validation settings when disabling open text validation (#7464)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 09:39:42 +00:00
1943 changed files with 127739 additions and 53258 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
+9
View File
@@ -0,0 +1,9 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''
+90 -2
View File
@@ -32,12 +32,54 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
##############
# DATABASE #
##############
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/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 #
################
@@ -94,6 +136,12 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1
@@ -132,6 +180,32 @@ AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET=
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
# 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=
# Amazon Bedrock credentials
# AI_AWS_REGION=
# AI_AWS_ACCESS_KEY_ID=
# AI_AWS_SECRET_ACCESS_KEY=
# AI_AWS_SESSION_TOKEN=
# Azure AI / Microsoft Foundry credentials
# AI_AZURE_BASE_URL=
# AI_AZURE_RESOURCE_NAME=
# AI_AZURE_API_KEY=
# AI_AZURE_API_VERSION=v1
# OpenID Connect (OIDC) configuration
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
@@ -150,7 +224,6 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PRICING_TABLE_ID=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -186,6 +259,14 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
# TELEMETRY_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -230,6 +311,13 @@ REDIS_URL=redis://localhost:6379
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0
# Optional Cube.js database overrides. The official local docker-compose.dev.yml stack points Cube at the
# local `postgres` service automatically; set these only when running Cube yourself or changing bundled defaults.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGO_API_KEY=your_api_key_here
+3 -5
View File
@@ -57,16 +57,14 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
+3 -7
View File
@@ -68,16 +68,12 @@ jobs:
run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
+2 -9
View File
@@ -31,15 +31,8 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- name: Create .env
run: pnpm dev:setup
- name: Lint
run: pnpm lint
+3 -7
View File
@@ -35,15 +35,11 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
- name: Adjust CI-specific env values
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
+3 -7
View File
@@ -32,15 +32,11 @@ jobs:
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
- name: Create .env
run: pnpm dev:setup
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
- name: Adjust CI-specific env values
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv
# Playwright
/test-results/
**/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+13 -1
View File
@@ -1 +1,13 @@
pnpm lint-staged
#!/usr/bin/env sh
if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
elif command -v npm >/dev/null 2>&1; then
npm exec --yes pnpm@10.32.1 lint-staged
elif command -v corepack >/dev/null 2>&1; then
corepack pnpm lint-staged
else
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "Install Node.js tooling or update your PATH, then retry the commit."
exit 127
fi
+9
View File
@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns
@@ -52,6 +53,14 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.
+1 -25
View File
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community-managed One Click Hosting
##### Railway
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development
### Prerequisites
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<p align="right"><a href="#top">🔼 Back to top</a></p>
<a id="readme-de"></a>
+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.1",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -1,64 +0,0 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps {
params: Promise<{
environmentId: string;
}>;
}
const Page = async (props: XMTemplatePageProps) => {
const params = await props.params;
const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new Error(t("common.session_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
</div>
);
};
export default Page;
@@ -1,20 +1,32 @@
"use client";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Loader2,
LogOutIcon,
PlusIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
@@ -22,14 +34,65 @@ import {
interface LandingSidebarProps {
user: TUser;
organization: TOrganization;
isMultiOrgEnabled: boolean;
}
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const { t } = useTranslation();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch {
setOrganizationLoadError(t("common.failed_to_load_organizations"));
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
isOrgDropdownOpen &&
organizations.length === 0 &&
!isLoadingOrganizations &&
!organizationLoadError
) {
loadOrganizations();
}
}, [
isOrgDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
const handleOrganizationChange = (orgId: string) => {
startTransition(() => {
setIsOrgDropdownOpen(false);
router.push(`/organizations/${orgId}/`);
});
};
const dropdownNavigation = [
{
label: t("common.documentation"),
@@ -39,52 +102,109 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
},
];
const switcherTriggerClasses =
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset";
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
return (
<aside
className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
<div className="flex items-center">
<div className="flex flex-col">
{/* Organization Switcher */}
<DropdownMenu onOpenChange={setIsOrgDropdownOpen}>
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && organizationLoadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
<button
onClick={() => {
setOrganizationLoadError(null);
setOrganizations([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* User Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<button
type="button"
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
aria-haspopup="menu">
<ProfileAvatar userId={user.id} />
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
{dropdownNavigation.map((link) => (
<Link
key={link.href}
id={link.href}
href={link.href}
target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -95,8 +215,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
@@ -105,7 +223,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -114,6 +231,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
</DropdownMenuContent>
</DropdownMenu>
</div>
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
</aside>
);
@@ -1,8 +1,7 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: {
@@ -24,16 +23,11 @@ const LandingLayout = async (props: {
return notFound();
}
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) {
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
return redirect(`/environments/${prodEnvironment.id}/`);
}
if (workspaces.length !== 0) {
const firstWorkspace = workspaces[0];
return redirect(`/workspaces/${firstWorkspace.id}/`);
}
return <>{children}</>;
@@ -1,9 +1,8 @@
import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -26,26 +25,25 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
return (
<div className="flex min-h-full min-w-full flex-row">
<LandingSidebar user={user} organization={organization} />
<LandingSidebar user={user} organization={organization} isMultiOrgEnabled={isMultiOrgEnabled} />
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<WorkspaceAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
environments={[]}
isMembershipPending={isMembershipPending}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: {
const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
return (
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
);
};
export default ProjectOnboardingLayout;
export default WorkspaceOnboardingLayout;
@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -1,12 +1,13 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
@@ -28,15 +29,15 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id),
getOrganizationWorkspacesCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`);
}
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -13,8 +13,8 @@ export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboard
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
title={t("workspace.settings.billing.select_plan_header_title")}
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div>
@@ -1,8 +1,12 @@
import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
interface PlanPageProps {
params: Promise<{
organizationId: string;
@@ -22,6 +26,16 @@ const Page = async (props: PlanPageProps) => {
return redirect(`/auth/login`);
}
// Users with an existing paid/trial subscription should not be shown the trial page.
// Redirect them directly to the next onboarding step.
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
const currentPlan = billing?.stripe?.plan;
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
if (hasExistingSubscription) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
};
@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
TProjectMode,
TProjectUpdateInput,
ZProjectUpdateInput,
} from "@formbricks/types/project";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
TWorkspaceUpdateInput,
ZWorkspaceUpdateInput,
} from "@formbricks/types/workspace";
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps {
interface WorkspaceSettingsProps {
organizationId: string;
projectMode: TProjectMode;
channel: TProjectConfigChannel;
industry: TProjectConfigIndustry;
workspaceMode: TWorkspaceMode;
channel: TWorkspaceConfigChannel;
industry: TWorkspaceConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
userWorkspacesCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
export const WorkspaceSettings = ({
organizationId,
projectMode,
workspaceMode,
channel,
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
userWorkspacesCount,
publicDomain,
}: ProjectSettingsProps) => {
}: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
@@ -71,7 +71,7 @@ export const ProjectSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
const createWorkspaceResponse = await createWorkspaceAction({
organizationId,
data: {
...data,
@@ -81,26 +81,21 @@ export const ProjectSettings = ({
},
});
if (createProjectResponse?.data) {
// get production environment
const productionEnvironment = createProjectResponse.data.environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
if (createWorkspaceResponse?.data) {
if (globalThis.window !== undefined) {
// Remove filters when creating a new workspace
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
const workspaceId = createWorkspaceResponse.data.id;
if (channel === "app" || channel === "website") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
router.push(`/workspaces/${workspaceId}/connect`);
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (projectMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
router.push(`/workspaces/${workspaceId}/surveys`);
} else if (workspaceMode === "cx") {
router.push(`/workspaces/${workspaceId}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProjectResponse);
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage);
}
} catch (error) {
@@ -109,15 +104,15 @@ export const ProjectSettings = ({
}
};
const form = useForm<TProjectUpdateInput>({
const form = useForm<TWorkspaceUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
resolver: zodResolver(ZWorkspaceUpdateInput),
});
const projectName = form.watch("name");
const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -132,7 +127,7 @@ export const ProjectSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
@@ -184,7 +179,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField
control={form.control}
name="teamIds"
@@ -242,7 +237,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -1,30 +1,35 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ProjectSettingsPageProps {
interface WorkspaceSettingsPageProps {
params: Promise<{
organizationId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
mode?: TProjectMode;
channel?: TWorkspaceConfigChannel;
industry?: TWorkspaceConfigIndustry;
mode?: TWorkspaceMode;
}>;
}
const Page = async (props: ProjectSettingsPageProps) => {
const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -38,14 +43,14 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
throw new ResourceNotFoundError(t("common.team"), null);
}
const publicDomain = getPublicDomain();
@@ -56,18 +61,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
<WorkspaceSettings
organizationId={params.organizationId}
projectMode={mode}
workspaceMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
userWorkspacesCount={workspaces.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -4,21 +4,20 @@ import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps {
environment: TEnvironment;
workspaceId: string;
publicDomain: string;
appSetupCompleted: boolean;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
}
export const ConnectWithFormbricks = ({
environment,
workspaceId,
publicDomain,
appSetupCompleted,
channel,
@@ -26,7 +25,7 @@ export const ConnectWithFormbricks = ({
const { t } = useTranslation();
const router = useRouter();
const handleFinishOnboarding = async () => {
router.push(`/environments/${environment.id}/surveys`);
router.push(`/workspaces/${workspaceId}/surveys`);
};
useEffect(() => {
@@ -48,7 +47,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
workspaceId={workspaceId}
publicDomain={publicDomain}
channel={channel}
appSetupCompleted={appSetupCompleted}
@@ -61,9 +60,9 @@ export const ConnectWithFormbricks = ({
)}>
{appSetupCompleted ? (
<div>
<p className="text-3xl">{t("environments.connect.congrats")}</p>
<p className="text-3xl">{t("workspace.connect.congrats")}</p>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.connection_successful_message")}
{t("workspace.connect.connection_successful_message")}
</p>
</div>
) : (
@@ -73,7 +72,7 @@ export const ConnectWithFormbricks = ({
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.waiting_for_your_signal")}
{t("workspace.connect.waiting_for_your_signal")}
</p>
</div>
)}
@@ -83,9 +82,7 @@ export const ConnectWithFormbricks = ({
id="finishOnboarding"
variant={appSetupCompleted ? "default" : "ghost"}
onClick={handleFinishOnboarding}>
{appSetupCompleted
? t("environments.connect.finish_onboarding")
: t("environments.connect.do_it_later")}
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
<ArrowRight />
</Button>
</div>
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -17,14 +17,14 @@ const tabs = [
];
interface OnboardingSetupInstructionsProps {
environmentId: string;
workspaceId: string;
publicDomain: string;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean;
}
export const OnboardingSetupInstructions = ({
environmentId,
workspaceId,
publicDomain,
channel,
appSetupCompleted,
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var workspaceId = "${workspaceId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
<!-- END Formbricks Surveys -->
`;
const npmSnippetForAppSurveys = `
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
const npmSnippetForWebsiteSurveys = `
// other imports
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
return (
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
{t("workspace.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
{t("workspace.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
@@ -1,55 +1,50 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ConnectPageProps {
params: Promise<{
environmentId: string;
workspaceId: string;
}>;
}
const Page = async (props: ConnectPageProps) => {
const params = await props.params;
const t = await getTranslate();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;
const channel = workspace.config.channel || null;
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} />
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<ConnectWithFormbricks
environment={environment}
workspaceId={params.workspaceId}
publicDomain={publicDomain}
appSetupCompleted={environment.appSetupCompleted}
appSetupCompleted={workspace.appSetupCompleted}
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>
<Link href={`/workspaces/${params.workspaceId}`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
@@ -1,11 +1,11 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
return redirect(`/auth/login`);
}
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
if (!isAuthorized) {
throw new AuthorizationError("User is not authorized to access this environment");
throw new AuthorizationError("User is not authorized to access this workspace");
}
return <div className="flex-1 bg-slate-50">{children}</div>;
@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps {
project: TProject;
workspace: TWorkspace;
user: TUser;
environmentId: string;
workspaceId: string;
}
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
createdBy: user.id,
};
const createSurveyResponse = await createSurveyAction({
environmentId: environmentId,
workspaceId: workspaceId,
surveyBody: augmentedTemplate,
});
if (createSurveyResponse?.data) {
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
} else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage);
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project);
const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate);
};
const XMTemplateOptions = [
{
title: t("environments.xm-templates.nps"),
description: t("environments.xm-templates.nps_description"),
title: t("workspace.xm-templates.nps"),
description: t("workspace.xm-templates.nps_description"),
icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0,
},
{
title: t("environments.xm-templates.five_star_rating"),
description: t("environments.xm-templates.five_star_rating_description"),
title: t("workspace.xm-templates.five_star_rating"),
description: t("workspace.xm-templates.five_star_rating_description"),
icon: StarIcon,
onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1,
},
{
title: t("environments.xm-templates.csat"),
description: t("environments.xm-templates.csat_description"),
title: t("workspace.xm-templates.csat"),
description: t("workspace.xm-templates.csat_description"),
icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2,
},
{
title: t("environments.xm-templates.ces"),
description: t("environments.xm-templates.ces_description"),
title: t("workspace.xm-templates.ces"),
description: t("workspace.xm-templates.ces_description"),
icon: ActivityIcon,
onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3,
},
{
title: t("environments.xm-templates.smileys"),
description: t("environments.xm-templates.smileys_description"),
title: t("workspace.xm-templates.smileys"),
description: t("workspace.xm-templates.smileys_description"),
icon: SmileIcon,
onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4,
},
{
title: t("environments.xm-templates.enps"),
description: t("environments.xm-templates.enps_description"),
title: t("workspace.xm-templates.enps"),
description: t("workspace.xm-templates.enps_description"),
icon: UsersIcon,
onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5,
@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
const mockWorkspace: TWorkspace = {
id: "workspace1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
name: "Test Workspace",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
@@ -27,12 +27,12 @@ const mockProject: TProject = {
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
environments: [],
appSetupCompleted: false,
languages: [],
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
name: "$[workspaceName] Survey",
blocks: [
{
id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
headline: { default: "$[workspaceName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
],
styling: {
brandColor: { light: "#0000FF" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
elementHeadlineColor: { light: "#00FF00" },
inputBgColor: { light: "#FF0000" },
},
};
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Workspace Survey");
});
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});
@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
// replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
};
@@ -0,0 +1,58 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
import { getUser } from "@/lib/user/service";
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps {
params: Promise<{
workspaceId: string;
}>;
}
const Page = async (props: XMTemplatePageProps) => {
const params = await props.params;
const session = await getServerSession(authOptions);
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
</div>
);
};
export default Page;
@@ -1,36 +0,0 @@
import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new Error(t("common.user_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorEnvironmentLayout;
@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getWorkspace } from "@/lib/workspace/service";
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
const SurveyEditorWorkspaceLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorWorkspaceLayout;
@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
export const ConfirmationPage = () => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
useEffect(() => {
setShowConfetti(true);
@@ -20,11 +20,9 @@ export const ConfirmationPage = () => {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
if (storedWorkspaceId) {
setResolvedWorkspaceId(storedWorkspaceId);
}
}, []);
@@ -43,9 +41,7 @@ export const ConfirmationPage = () => {
<Button asChild className="w-full justify-center">
<Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/"
}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
@@ -1,4 +1,4 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
export const LoadingCard = ({
@@ -1,18 +0,0 @@
"use client";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
interface EnvironmentStorageHandlerProps {
environmentId: string;
}
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
}, [environmentId]);
return null;
};
export default EnvironmentStorageHandler;
@@ -1,56 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface EnvironmentSwitchProps {
environment: TEnvironment;
environments: TEnvironment[];
}
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentType: "production" | "development") => {
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
if (newEnvironmentId) {
router.push(`/environments/${newEnvironmentId}/`);
}
};
const toggleEnvSwitch = () => {
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
setIsLoading(true);
setIsEnvSwitchChecked(!isEnvSwitchChecked);
handleEnvironmentChange(newEnvironmentType);
};
return (
<div
className={cn(
"flex items-center space-x-2 rounded-lg p-2",
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
)}>
<Label
htmlFor="development-mode"
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
{t("common.dev_env")}
</Label>
<Switch
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
id="development-mode"
disabled={isLoading}
checked={isEnvSwitchChecked}
onCheckedChange={toggleEnvSwitch}
/>
</div>
);
};
@@ -1,324 +0,0 @@
"use client";
import {
ArrowUpRightIcon,
ChevronRightIcon,
Cog,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import packageJson from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
project: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
environment,
organization,
user,
project,
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
};
useEffect(() => {
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
setIsCollapsed(isCollapsedValueFromLocalStorage);
}, []);
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
}, [isCollapsed]);
useEffect(() => {
// Auto collapse project navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
}, [pathname]);
const mainNavigation = useMemo(
() => [
{
name: t("common.surveys"),
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/workspace"),
},
],
[t, environment.id, pathname, isFormbricksCloud]
);
const dropdownNavigation = [
{
label: t("common.account"),
href: `/environments/${environment.id}/settings/profile`,
icon: UserCircleIcon,
},
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
},
{
label: t("common.share_feedback"),
href: "https://github.com/formbricks/formbricks/issues",
target: "_blank",
icon: ArrowUpRightIcon,
},
];
useEffect(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
if (res?.data) {
const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`;
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
setLatestVersion(latestVersionTag);
}
}
}
if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return (
<>
{project && (
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
)}>
<div>
{/* Logo and Toggle */}
<div className="flex items-center justify-between px-3 pb-4">
{!isCollapsed && (
<Link
href={mainNavigationLink}
className={cn(
"flex items-center justify-center transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
) : (
<PanelLeftCloseIcon strokeWidth={1.5} />
)}
</Button>
</div>
{/* Main Nav Switch */}
{!isBilling && (
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
)}
</div>
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
{/* User Switch */}
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div
className={cn(
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} />
{!isCollapsed && !isTextVisible && (
<>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-700">{t("common.account")}</p>
</div>
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{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>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearEnvironmentId: true,
});
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</aside>
)}
</>
);
};
@@ -1,66 +0,0 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps {
href: string;
isActive: boolean;
isCollapsed: boolean;
children: React.ReactNode;
linkText: string;
isTextVisible: boolean;
}
export const NavigationLink = ({
href,
isActive,
isCollapsed = false,
children,
linkText,
isTextVisible = true,
}: NavigationLinkProps) => {
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";
return (
<>
{isCollapsed ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li
className={cn(
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
isActive ? activeClass : inactiveClass
)}>
<Link href={href} className="flex items-center">
{children}
</Link>
</li>
</TooltipTrigger>
<TooltipContent side="right">{linkText}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<li
className={cn(
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
isActive ? activeClass : inactiveClass
)}>
<Link href={href} className="flex items-center">
{children}
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
</Link>
</li>
)}
</>
);
};
@@ -1,89 +0,0 @@
"use client";
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
export const EnvironmentBreadcrumb = ({
environments,
currentEnvironment,
}: {
environments: { id: string; type: string }[];
currentEnvironment: { id: string; type: string };
}) => {
const { t } = useTranslation();
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentId: string) => {
if (environmentId === currentEnvironment.id) return;
setIsLoading(true);
router.push(`/environments/${environmentId}/`);
};
const developmentTooltip = () => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<CircleHelpIcon className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
{t("common.development_environment_banner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
return (
<BreadcrumbItem
isActive={isEnvironmentDropdownOpen}
isHighlighted={currentEnvironment.type === "development"}>
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="environmentDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
<span className="capitalize">{currentEnvironment.type}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{currentEnvironment.type === "development" && developmentTooltip()}
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2" align="start">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Code2Icon className="mr-2 inline h-4 w-4" />
{t("common.choose_environment")}
</div>
<DropdownMenuGroup>
{environments.map((env) => (
<DropdownMenuCheckboxItem
key={env.id}
checked={env.type === currentEnvironment.type}
onClick={() => handleEnvironmentChange(env.id)}
className="cursor-pointer">
<div className="flex items-center gap-2 capitalize">
<span>{env.type}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
);
};
@@ -1,74 +0,0 @@
"use client";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
}: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
/>
)}
{showEnvironmentBreadcrumb && (
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
)}
</BreadcrumbList>
</Breadcrumb>
);
};
@@ -1,297 +0,0 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
}
// Check if path matches /workspace/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
}: ProjectBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
// Lazy-load projects when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${currentEnvironmentId}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${currentEnvironmentId}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${currentEnvironmentId}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
];
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
});
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${currentEnvironmentId}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
</BreadcrumbItem>
);
};
@@ -1,68 +0,0 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
export const useProject = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
}
return { project: context.project };
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organization,
organizationId: project.organizationId,
}),
[environment, project, organization]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};
@@ -1,38 +0,0 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
// Check session first (required for userId)
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Single consolidated data fetch (replaces ~12 individual fetches)
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</>
);
};
export default EnvLayout;
@@ -1,25 +0,0 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
if (IS_FORMBRICKS_CLOUD) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
} else {
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
}
}
return redirect(`/environments/${params.environmentId}/surveys`);
};
export default EnvironmentPage;
@@ -1,37 +0,0 @@
import { getServerSession } from "next-auth";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
return <>{children}</>;
};
export default AccountSettingsLayout;
@@ -1,6 +0,0 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import Loading from "@/modules/organization/settings/api-keys/loading";
export default function LoadingPage() {
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
@@ -1,74 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { getAccessFlags } from "@/lib/membership/utils";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface OrganizationSettingsNavbarProps {
environmentId?: string;
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole;
activeId: string;
loading?: boolean;
}
export const OrganizationSettingsNavbar = ({
environmentId,
isFormbricksCloud,
membershipRole,
activeId,
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember;
const { t } = useTranslation();
const navigation = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environmentId}/settings/general`,
current: pathname?.includes("/general"),
hidden: false,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
hidden: !isOwner,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environmentId}/settings/domain`,
current: pathname?.includes("/domain"),
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud || loading,
current: pathname?.includes("/billing"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -1,69 +0,0 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ name: true }),
});
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
);
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
})
);
@@ -1,36 +0,0 @@
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Loading = async () => {
const t = await getTranslate();
const cards = [
{
title: t("environments.settings.general.organization_name"),
description: t("environments.settings.general.organization_name_description"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
},
{
title: t("environments.settings.general.delete_organization"),
description: t("environments.settings.general.delete_organization_description"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
},
];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="general" loading />
</PageHeader>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</PageContentWrapper>
);
};
export default Loading;
@@ -1,34 +0,0 @@
import { getServerSession } from "next-auth";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
return <>{children}</>;
};
export default Layout;
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};
export default Page;
@@ -1,164 +0,0 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
surveyId: ZId,
format: z.union([z.literal("csv"), z.literal("xlsx")]),
filterCriteria: ZResponseFilterCriteria,
});
export const getResponsesDownloadUrlAction = authenticatedActionClient
.inputSchema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
});
const ZGetSurveyFilterDataAction = z.object({
surveyId: ZId,
});
export const getSurveyFilterDataAction = authenticatedActionClient
.inputSchema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId),
getResponseFilteringValues(parsedInput.surveyId),
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
]);
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
};
export default Page;
@@ -1,208 +0,0 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -1,42 +0,0 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
export default Page;
@@ -1,3 +0,0 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;
@@ -1,3 +0,0 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
export default AppConnectionPage;
@@ -1,3 +0,0 @@
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
export default GeneralSettingsLoading;
@@ -1,3 +0,0 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
export default GeneralSettingsPage;
@@ -1,51 +0,0 @@
import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
]);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
<div className="h-[75vh] w-full">
<SlackWrapper
isEnabled={isEnabled}
environment={environment}
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
locale={locale}
/>
</div>
</PageContentWrapper>
);
};
export default Page;
@@ -1,3 +0,0 @@
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading;
@@ -1,3 +0,0 @@
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;
@@ -1,4 +0,0 @@
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata };
export default ProjectSettingsLayout;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
export default ProjectLookSettingsLoading;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
export default ProjectLookSettingsPage;
@@ -1,3 +0,0 @@
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
export default ProjectSettingsPage;
@@ -1,3 +0,0 @@
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
export default TagsLoading;
@@ -1,3 +0,0 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
export default TagsPage;
@@ -1,3 +0,0 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
export default ProjectTeams;
+4 -2
View File
@@ -6,8 +6,10 @@ import {
CHATWOOT_WEBSITE_TOKEN,
IS_CHATWOOT_CONFIGURED,
POSTHOG_KEY,
SESSION_MAX_AGE,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
@@ -23,7 +25,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
}
return (
<>
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
<NoMobileOverlay />
{POSTHOG_KEY && user && (
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
@@ -39,7 +41,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
)}
<ToasterClient />
{children}
</>
</NextAuthProvider>
);
};
@@ -0,0 +1,8 @@
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <ChartsListPage workspaceId={workspaceId} />;
};
export default ChartsPage;
@@ -0,0 +1,7 @@
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
return <DashboardDetailPage params={props.params} />;
};
export default Page;
@@ -0,0 +1,8 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <DashboardsListPage workspaceId={workspaceId} />;
};
export default DashboardsPage;
@@ -0,0 +1,3 @@
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
export default AnalysisListLoading;
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
export default async function FeedbackSourcesRedirect(
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
) {
const { workspaceId } = await props.params;
redirect(`/workspaces/${workspaceId}/settings/workspace/feedback-sources`);
}
@@ -2,30 +2,38 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getOrganizationWorkspacesLimit,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
import { getOrganizationsByUserId } from "./lib/organization";
import { getProjectsByUserId } from "./lib/project";
import { getWorkspacesByUserId } from "./lib/workspace";
const ZCreateProjectAction = z.object({
organizationId: ZId,
data: ZProjectUpdateInput,
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
feedbackDirectoryId: ZId.optional(),
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
const ZCreateWorkspaceAction = z.object({
organizationId: ZId,
data: ZCreateWorkspaceInput,
});
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
@@ -36,7 +44,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
schema: ZCreateWorkspaceInput,
type: "organization",
roles: ["owner", "manager"],
},
@@ -46,13 +54,13 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", organizationId);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
@@ -64,7 +72,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
@@ -77,14 +85,14 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
ctx.auditLoggingCtx.workspaceId = workspace.id;
ctx.auditLoggingCtx.newObject = workspace;
return workspace;
})
);
const ZGetOrganizationsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
organizationId: ZId, // Changed from workspaceId to avoid extra query
});
/**
@@ -108,16 +116,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
return await getOrganizationsByUserId(ctx.user.id);
});
const ZGetProjectsForSwitcherAction = z.object({
organizationId: ZId, // Changed from environmentId to avoid extra query
const ZGetWorkspacesForSwitcherAction = z.object({
organizationId: ZId, // Changed from workspaceId to avoid extra query
});
/**
* Fetches projects list for switcher dropdown.
* Called on-demand when user opens the project switcher.
* Fetches workspaces list for switcher dropdown.
* Called on-demand when user opens the workspace switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction)
export const getWorkspacesForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetWorkspacesForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -130,11 +138,11 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
],
});
// Need membership for getProjectsByUserId (1 DB query)
// Need membership for getWorkspacesByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) {
throw new AuthorizationError("Membership not found");
}
return await getProjectsByUserId(ctx.user.id, membership);
return await getWorkspacesByUserId(ctx.user.id, membership);
});
@@ -0,0 +1,840 @@
"use client";
import {
ArrowUpRightIcon,
BarChart3Icon,
Building2Icon,
ChevronRightIcon,
Cog,
FoldersIcon,
Loader2,
LogOutIcon,
MessageCircle,
MessageSquareTextIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import {
getOrganizationsForSwitcherAction,
getWorkspacesForSwitcherAction,
} from "@/app/(app)/workspaces/[workspaceId]/actions";
import { NavigationLink } from "@/app/(app)/workspaces/[workspaceId]/components/NavigationLink";
import { SettingsSidebarContent } from "@/app/(app)/workspaces/[workspaceId]/components/SettingsSidebarContent";
import { isNewerVersion } from "@/app/(app)/workspaces/[workspaceId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
import packageJson from "../../../../../package.json";
interface NavigationProps {
user: TUser;
organization: TOrganization;
workspace: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
}
export const MainNavigation = ({
organization,
user,
workspace,
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
isMultiOrgEnabled,
organizationWorkspacesLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const [isPending, startTransition] = useTransition();
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const disabledNavigationMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
const isOwnerOrManager = isManager || isOwner;
const isSettingsMode = pathname?.includes("/settings");
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
};
useEffect(() => {
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
setIsCollapsed(isCollapsedValueFromLocalStorage);
}, []);
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
}, [isCollapsed]);
const mainNavigationSections = useMemo(
() => [
{
id: "ask",
name: t("common.ask"),
items: [
{
name: t("common.surveys"),
href: `/workspaces/${workspace.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/workspaces/${workspace.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
},
],
},
{
id: "unify-feedback",
name: t("workspace.unify.unify_feedback"),
items: [
{
name: t("workspace.unify.feedback_records"),
href: `/workspaces/${workspace.id}/unify/feedback-records`,
icon: MessageSquareTextIcon,
isActive: pathname?.includes("/unify/feedback-records"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
name: t("common.dashboards"),
href: `/workspaces/${workspace.id}/dashboards`,
icon: BarChart3Icon,
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
],
},
],
[t, workspace.id, pathname, isMembershipPending, isBilling]
);
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
}),
[t, workspace.id, isSettingsMode, isMembershipPending, isBilling]
);
const dropdownNavigation = [
{
label: t("common.account"),
href: `/workspaces/${workspace.id}/settings/account/profile`,
icon: UserCircleIcon,
},
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
},
{
label: t("common.share_feedback"),
href: "https://github.com/formbricks/formbricks/issues",
target: "_blank",
icon: ArrowUpRightIcon,
},
];
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
const [hasInitializedWorkspaces, setHasInitializedWorkspaces] = useState(false);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [openWorkspaceLimitModal, setOpenWorkspaceLimitModal] = useState(false);
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
);
const loadWorkspaces = useCallback(async () => {
setIsLoadingWorkspaces(true);
setWorkspaceLoadError(null);
try {
const result = await getWorkspacesForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setWorkspaces(sorted);
} else {
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setWorkspaceLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
);
} finally {
setIsLoadingWorkspaces(false);
setHasInitializedWorkspaces(true);
}
}, [organization.id, t]);
useEffect(() => {
if (!isWorkspaceDropdownOpen || workspaces.length > 0 || isLoadingWorkspaces || workspaceLoadError) {
return;
}
loadWorkspaces();
}, [isWorkspaceDropdownOpen, workspaces.length, isLoadingWorkspaces, workspaceLoadError, loadWorkspaces]);
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setOrganizationLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
);
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
!isOrganizationDropdownOpen ||
organizations.length > 0 ||
isLoadingOrganizations ||
organizationLoadError
) {
return;
}
loadOrganizations();
}, [
isOrganizationDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
useEffect(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
if (res?.data) {
const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`;
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
setLatestVersion(latestVersionTag);
}
}
}
if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = isBilling
? getBillingFallbackPath(workspace.id, isFormbricksCloud)
: `/workspaces/${workspace.id}/surveys/`;
const handleWorkspaceChange = (workspaceId: string) => {
const targetPath =
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/workspaces/${workspace.id}/settings/organization/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleWorkspaceCreate = () => {
if (!hasInitializedWorkspaces || isLoadingWorkspaces) {
return;
}
if (workspaces.length >= organizationWorkspacesLimit) {
setOpenWorkspaceLimitModal(true);
return;
}
setOpenCreateWorkspaceModal(true);
};
const workspaceLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("workspace.settings.billing.upgrade"),
href: `/workspaces/${workspace.id}/settings/organization/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenWorkspaceLimitModal(false),
},
];
}
return [
{
text: t("workspace.settings.billing.upgrade"),
href: isLicenseActive
? `/workspaces/${workspace.id}/settings/organization/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenWorkspaceLimitModal(false),
},
];
};
const handleSettingsWorkspaceChange = useCallback(
(id: string) => {
startTransition(() => {
router.push(`/workspaces/${id}/settings/workspace/general`);
});
},
[router]
);
const handleSettingsOrganizationChange = useCallback(
(id: string) => {
startTransition(() => {
if (id === organization.id) {
router.push(`/workspaces/${workspace.id}/settings/organization/general`);
} else {
router.push(`/organizations/${id}/`);
}
});
},
[router, organization.id, workspace.id]
);
const switcherTriggerClasses = cn(
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialWorkspacesLoading =
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
return (
<>
{workspace && (
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
isSettingsMode || !isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
)}>
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
{/* Settings sidebar content */}
<SettingsSidebarContent
workspaceId={workspace.id}
workspaceName={workspace.name}
organizationId={organization.id}
organizationName={organization.name}
membershipRole={membershipRole}
isFormbricksCloud={isFormbricksCloud}
isCollapsed={false}
isTextVisible={false}
workspaces={workspaces}
isLoadingWorkspaces={isLoadingWorkspaces}
onWorkspaceChange={handleSettingsWorkspaceChange}
onWorkspaceDropdownOpen={loadWorkspaces}
organizations={organizations}
isLoadingOrganizations={isLoadingOrganizations}
onOrganizationChange={handleSettingsOrganizationChange}
onOrganizationDropdownOpen={loadOrganizations}
/>
</div>
) : (
<div>
{/* Logo and Toggle */}
<div className="flex items-center justify-between px-3 pb-4">
{!isCollapsed && (
<Link
href={mainNavigationLink}
className={cn(
"flex items-center justify-center transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
) : (
<PanelLeftCloseIcon strokeWidth={1.5} />
)}
</Button>
</div>
{/* Main Nav */}
<ul className="space-y-2">
{mainNavigationSections.map((section) => (
<li key={section.id}>
{!isCollapsed && !isTextVisible && (
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
{section.name}
</p>
)}
<ul>
{section.items.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
</li>
))}
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
<ul>
<NavigationLink
href={settingsNavigationItem.href}
isActive={settingsNavigationItem.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={settingsNavigationItem.disabled}
disabledMessage={
settingsNavigationItem.disabled ? disabledNavigationMessage : undefined
}
linkText={settingsNavigationItem.name}>
<settingsNavigationItem.icon strokeWidth={1.5} />
</NavigationLink>
</ul>
</li>
</ul>
</div>
)}
{!isSettingsMode && (
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link
href={`/workspaces/${workspace.id}/settings/organization/billing`}
className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger
asChild
id="workspaceDropdownTrigger"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{workspace.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingWorkspaces || isInitialWorkspacesLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingWorkspaces &&
!isInitialWorkspacesLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setWorkspaces([]);
},
t("common.try_again")
)}
{!isLoadingWorkspaces && !isInitialWorkspacesLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{workspaces.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === workspace.id}
onClick={() => handleWorkspaceChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleWorkspaceCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/workspace/general`)
}
className="cursor-pointer">
<Cog className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
onClick={() =>
handleSettingNavigation(`/workspaces/${workspace.id}/settings/organization/general`)
}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button
type="button"
aria-label={isCollapsed ? t("common.account_settings") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p
title={user?.email}
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearWorkspaceId: true,
});
router.push(route?.url || loginUrl);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</aside>
)}
{openWorkspaceLimitModal && (
<WorkspaceLimitModal
open={openWorkspaceLimitModal}
setOpen={setOpenWorkspaceLimitModal}
buttons={workspaceLimitModalButtons()}
workspaceLimit={organizationWorkspacesLimit}
/>
)}
{openCreateWorkspaceModal && (
<CreateWorkspaceModal
open={openCreateWorkspaceModal}
setOpen={setOpenCreateWorkspaceModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</>
);
};
@@ -0,0 +1,95 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps {
href: string;
isActive: boolean;
isCollapsed: boolean;
children: React.ReactNode;
linkText: string;
isTextVisible: boolean;
disabled?: boolean;
disabledMessage?: string;
}
export const NavigationLink = ({
href,
isActive,
isCollapsed = false,
children,
linkText,
isTextVisible = true,
disabled = false,
disabledMessage,
}: NavigationLinkProps) => {
const tooltipText = disabled ? disabledMessage || linkText : linkText;
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 getColorClass = (baseClass: string) => {
if (disabled) {
return disabledClass;
}
return cn(baseClass, isActive ? activeClass : inactiveClass);
};
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
const label = (
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
);
return (
<>
{isCollapsed ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
{disabled ? (
<div className="flex items-center">{children}</div>
) : (
<Link href={href}>{children}</Link>
)}
</li>
</TooltipTrigger>
<TooltipContent side="right">{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
{disabled ? (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center">
{children}
{label}
</div>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{disabledMessage || linkText}
</PopoverContent>
</Popover>
) : (
<Link href={href} className="flex items-center">
{children}
{label}
</Link>
)}
</li>
)}
</>
);
};
@@ -0,0 +1,450 @@
"use client";
import {
BellIcon,
BlocksIcon,
BrushIcon,
Building2Icon,
ChevronDownIcon,
CreditCardIcon,
FoldersIcon,
GlobeIcon,
KeyIcon,
LanguagesIcon,
ListChecksIcon,
Loader2,
ShapesIcon,
ShieldIcon,
TagIcon,
UnplugIcon,
UserCircleIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface SettingsSidebarContentProps {
workspaceId: string;
workspaceName: string;
organizationId: string;
organizationName: string;
membershipRole?: TOrganizationRole;
isFormbricksCloud: boolean;
isCollapsed: boolean;
isTextVisible: boolean;
// Workspace switcher
workspaces: { id: string; name: string }[];
isLoadingWorkspaces: boolean;
onWorkspaceChange: (id: string) => void;
onWorkspaceDropdownOpen: () => void;
// Organization switcher
organizations: { id: string; name: string }[];
isLoadingOrganizations: boolean;
onOrganizationChange: (id: string) => void;
onOrganizationDropdownOpen: () => void;
}
interface NavItem {
id: string;
label: string;
href: string;
icon: React.ReactNode;
hidden?: boolean;
disabled?: boolean;
}
const SettingsNavLink = ({
item,
isActive,
isCollapsed,
isTextVisible,
disabledMessage,
}: {
item: NavItem;
isActive: boolean;
isCollapsed: boolean;
isTextVisible: boolean;
disabledMessage?: string;
}) => {
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const isDisabled = item.disabled;
const getStateClass = () => {
if (isDisabled) return disabledClass;
return isActive ? activeClass : inactiveClass;
};
if (isCollapsed) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li className={cn("rounded-l-md py-1.5 pl-2 text-sm", getStateClass())}>
{isDisabled ? (
<div className="flex items-center">{item.icon}</div>
) : (
<Link href={item.href} className="flex items-center text-slate-600 hover:text-slate-900">
{item.icon}
</Link>
)}
</li>
</TooltipTrigger>
<TooltipContent side="right">
{isDisabled ? disabledMessage || item.label : item.label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (isDisabled) {
return (
<li className={cn("rounded-l-md py-1.5 pl-8 text-sm", disabledClass)}>
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center">
{item.icon}
<span
className={cn(
"ml-2 transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{item.label}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{disabledMessage || item.label}
</PopoverContent>
</Popover>
</li>
);
}
return (
<li
className={cn(
"rounded-l-md py-1.5 pl-8 text-sm",
isActive ? activeClass : inactiveClass,
"text-slate-600 hover:text-slate-900"
)}>
<Link href={item.href} className="flex items-center">
{item.icon}
<span
className={cn("ml-2 transition-opacity duration-100", isTextVisible ? "opacity-0" : "opacity-100")}>
{item.label}
</span>
</Link>
</li>
);
};
const SectionHeader = ({
icon,
label,
isCollapsed,
isTextVisible,
switcherName,
switcherItems,
isLoadingSwitcher,
currentId,
onSwitcherChange,
onSwitcherOpen,
}: {
icon: React.ReactNode;
label: string;
isCollapsed: boolean;
isTextVisible: boolean;
switcherName?: string;
switcherItems?: { id: string; name: string }[];
isLoadingSwitcher?: boolean;
currentId?: string;
onSwitcherChange?: (id: string) => void;
onSwitcherOpen?: () => void;
}) => {
if (isCollapsed) {
return <div className="mb-1 mt-3 flex justify-center px-2 text-slate-400">{icon}</div>;
}
return (
<div
className={cn(
"mb-1 mt-4 flex min-w-0 items-center gap-2 px-3",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<span className="text-slate-500">{icon}</span>
<span className="shrink-0 text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</span>
{switcherName && switcherItems && onSwitcherChange && (
<DropdownMenu onOpenChange={(open) => open && onSwitcherOpen?.()}>
<DropdownMenuTrigger className="ml-auto flex min-w-0 max-w-[50%] items-center gap-1 rounded-md border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50">
<span className="truncate">{switcherName}</span>
<ChevronDownIcon className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px]">
{isLoadingSwitcher ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<DropdownMenuGroup className="overflow-y-auto">
{switcherItems.map((item) => (
<DropdownMenuCheckboxItem
key={item.id}
checked={item.id === currentId}
onClick={() => onSwitcherChange(item.id)}
className="cursor-pointer text-sm">
{item.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};
export const SettingsSidebarContent = ({
workspaceId,
workspaceName,
organizationId,
organizationName,
membershipRole,
isFormbricksCloud,
isCollapsed,
isTextVisible,
workspaces,
isLoadingWorkspaces,
onWorkspaceChange,
onWorkspaceDropdownOpen,
organizations,
isLoadingOrganizations,
onOrganizationChange,
onOrganizationDropdownOpen,
}: SettingsSidebarContentProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { isMember, isBilling, isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const iconClassName = "h-4 w-4 shrink-0";
const basePath = `/workspaces/${workspaceId}/settings`;
const workspaceItems: NavItem[] = [
{
id: "general",
label: t("common.general"),
href: `${basePath}/workspace/general`,
icon: <FoldersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "teams",
label: t("common.team_access"),
href: `${basePath}/workspace/teams`,
icon: <UsersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `${basePath}/workspace/languages`,
icon: <LanguagesIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "app-connection",
label: t("common.connect_your_app"),
href: `${basePath}/workspace/app-connection`,
icon: <UnplugIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "feedback-sources",
label: t("workspace.unify.feedback_sources"),
href: `${basePath}/workspace/feedback-sources`,
icon: <ShapesIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "integrations",
label: t("common.integrations"),
href: `${basePath}/workspace/integrations`,
icon: <BlocksIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "look",
label: t("common.appearance"),
href: `${basePath}/workspace/look`,
icon: <BrushIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "user-actions",
label: t("common.user_actions"),
href: `${basePath}/workspace/user-actions`,
icon: <ListChecksIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "tags",
label: t("common.tags"),
href: `${basePath}/workspace/tags`,
icon: <TagIcon className={iconClassName} />,
disabled: isBilling,
},
];
const organizationItems: NavItem[] = [
{
id: "org-general",
label: t("common.general"),
href: `${basePath}/organization/general`,
icon: <Building2Icon className={iconClassName} />,
disabled: isBilling,
},
{
id: "org-teams",
label: t("common.teams"),
href: `${basePath}/organization/teams`,
icon: <UsersIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "org-feedback-directories",
label: t("workspace.settings.feedback_directories.nav_label"),
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
},
{
id: "org-api-keys",
label: t("common.api_keys"),
href: `${basePath}/organization/api-keys`,
icon: <KeyIcon className={iconClassName} />,
hidden: !isOwnerOrManager,
},
{
id: "org-domain",
label: t("common.domain"),
href: `${basePath}/organization/domain`,
icon: <GlobeIcon className={iconClassName} />,
hidden: isFormbricksCloud,
},
{
id: "org-billing",
label: t("common.billing"),
href: `${basePath}/organization/billing`,
icon: <CreditCardIcon className={iconClassName} />,
hidden: !isFormbricksCloud,
},
{
id: "org-enterprise",
label: t("common.enterprise_license"),
href: `${basePath}/organization/enterprise`,
icon: <ShieldIcon className={iconClassName} />,
hidden: isFormbricksCloud,
disabled: isMember || isBilling,
},
];
const accountItems: NavItem[] = [
{
id: "profile",
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
},
];
const disabledMessage = t("common.you_are_not_authorized_to_perform_this_action");
const renderSection = (items: NavItem[]) => {
const visibleItems = items.filter((item) => !item.hidden);
return (
<ul className="space-y-0.5">
{visibleItems.map((item) => (
<SettingsNavLink
key={item.id}
item={item}
isActive={pathname.includes(item.href)}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabledMessage={item.disabled ? disabledMessage : undefined}
/>
))}
</ul>
);
};
return (
<div className="flex flex-col overflow-y-auto">
<div>
<SectionHeader
icon={<FoldersIcon className="h-4 w-4" />}
label={t("common.workspace")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
switcherName={workspaceName}
switcherItems={workspaces}
isLoadingSwitcher={isLoadingWorkspaces}
currentId={workspaceId}
onSwitcherChange={onWorkspaceChange}
onSwitcherOpen={onWorkspaceDropdownOpen}
/>
{renderSection(workspaceItems)}
</div>
<div>
<SectionHeader
icon={<Building2Icon className="h-4 w-4" />}
label={t("common.organization")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
switcherName={organizationName}
switcherItems={organizations}
isLoadingSwitcher={isLoadingOrganizations}
currentId={organizationId}
onSwitcherChange={onOrganizationChange}
onSwitcherOpen={onOrganizationDropdownOpen}
/>
{renderSection(organizationItems)}
</div>
<div>
<SectionHeader
icon={<UserCircleIcon className="h-4 w-4" />}
label={t("common.account")}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
/>
{renderSection(accountItems)}
</div>
</div>
);
};
@@ -1,17 +1,13 @@
"use client";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
currentProjectId: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
@@ -20,35 +16,31 @@ interface TopControlBarProps {
}
export const TopControlBar = ({
environments,
currentOrganizationId,
currentProjectId,
isMultiOrgEnabled,
organizationProjectsLimit,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
membershipRole,
}: TopControlBarProps) => {
const { isMember } = getAccessFlags(membershipRole);
const { environment } = useEnvironment();
const { workspace } = useWorkspaceContext();
const isMembershipPending = membershipRole === undefined;
return (
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<ProjectAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
<WorkspaceAndOrgSwitch
currentWorkspaceId={workspace.id}
currentOrganizationId={currentOrganizationId}
currentProjectId={currentProjectId}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
@@ -3,33 +3,32 @@
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
interface WidgetStatusIndicatorProps {
environment: TEnvironment;
workspace: { appSetupCompleted: boolean };
}
export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps) => {
const { t } = useTranslation();
const router = useRouter();
const stati = {
notImplemented: {
icon: AlertTriangleIcon,
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
title: t("workspace.app-connection.formbricks_sdk_not_connected"),
subtitle: t("workspace.app-connection.formbricks_sdk_not_connected_description"),
},
running: {
icon: CheckIcon,
title: t("environments.workspace.app-connection.receiving_data"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
title: t("workspace.app-connection.receiving_data"),
subtitle: t("workspace.app-connection.formbricks_sdk_connected"),
},
};
let status: "notImplemented" | "running";
if (environment.appSetupCompleted) {
if (workspace.appSetupCompleted) {
status = "running";
} else {
status = "notImplemented";
@@ -57,7 +56,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />
{t("environments.workspace.app-connection.recheck")}
{t("workspace.app-connection.recheck")}
</Button>
)}
</div>
@@ -1,33 +1,32 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/workspaces/[workspaceId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { TWorkspaceLayoutData } from "@/modules/workspaces/types/workspace-auth";
interface EnvironmentLayoutProps {
layoutData: TEnvironmentLayoutData;
interface WorkspaceLayoutProps {
layoutData: TWorkspaceLayoutData;
children?: React.ReactNode;
}
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
user,
environment,
organization,
membership,
project, // Current project details
environments, // All project environments (for environment switcher)
workspace, // Current workspace details
isAccessControlAllowed,
projectPermission,
workspacePermission,
license,
responseCount,
} = layoutData;
@@ -37,51 +36,47 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found"));
// Validate that workspace permission exists for members
if (isMember && !workspacePermission) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
{IS_FORMBRICKS_CLOUD && (
<LimitsReachedBanner
organization={organization}
environmentId={environment.id}
responseCount={responseCount}
/>
<LimitsReachedBanner organization={organization} responseCount={responseCount} />
)}
<PendingDowngradeBanner
lastChecked={lastChecked}
isPendingDowngrade={isPendingDowngrade ?? false}
active={active}
environmentId={environment.id}
locale={user.locale}
status={status}
/>
<div className="flex h-full">
<MainNavigation
environment={environment}
organization={organization}
user={user}
project={{ id: project.id, name: project.name }}
workspace={{ id: workspace.id, name: workspace.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environments={environments}
currentOrganizationId={organization.id}
currentProjectId={project.id}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}
@@ -1,13 +1,13 @@
import Link from "next/link";
import { ReactNode } from "react";
interface ProjectNavItemProps {
interface WorkspaceNavItemProps {
href: string;
children: ReactNode;
isActive: boolean;
}
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
const activeClass = "bg-slate-50 font-semibold";
const inactiveClass = "hover:bg-slate-50";
@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS, FORMBRICKS_WORKSPACE_ID_LS } from "@/lib/localStorage";
interface WorkspaceStorageHandlerProps {
workspaceId: string;
}
const WorkspaceStorageHandler = ({ workspaceId }: WorkspaceStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_WORKSPACE_ID_LS, workspaceId);
// Keep legacy environment ID in sync for backward compatibility with old SDK clients
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, workspaceId);
}, [workspaceId]);
return null;
};
export default WorkspaceStorageHandler;
@@ -9,11 +9,11 @@ import {
PlusIcon,
SettingsIcon,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
@@ -25,42 +25,24 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useOrganization } from "../context/environment-context";
import { useOrganization, useWorkspace } from "../context/workspace-context";
interface OrganizationBreadcrumbProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: pass directly if context not available
isMultiOrgEnabled: boolean;
currentEnvironmentId?: string;
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
currentWorkspaceId?: string;
}
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
// Match /settings/{settingId} or /settings/{settingId}/... but exclude account settings
// Exclude paths with /(account)/
if (pathname.includes("/(account)/")) {
return false;
}
// Check if path matches /settings/{settingId} (with optional trailing path)
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const OrganizationBreadcrumb = ({
currentOrganizationId,
currentOrganizationName,
isMultiOrgEnabled,
currentEnvironmentId,
isFormbricksCloud,
isMember,
isOwnerOrManager,
currentWorkspaceId,
}: OrganizationBreadcrumbProps) => {
const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const pathname = usePathname();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
@@ -70,6 +52,7 @@ export const OrganizationBreadcrumb = ({
// Get current organization name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { organization: currentOrganization } = useOrganization();
const { workspace } = useWorkspace();
const organizationName = currentOrganization?.name || currentOrganizationName || "";
// Lazy-load organizations when dropdown opens
@@ -110,9 +93,15 @@ export const OrganizationBreadcrumb = ({
return;
}
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}/`);
});
};
@@ -127,43 +116,6 @@ export const OrganizationBreadcrumb = ({
});
};
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${currentEnvironmentId}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
return (
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
@@ -232,27 +184,16 @@ export const OrganizationBreadcrumb = ({
)}
</>
)}
{currentEnvironmentId && (
<div>
{currentWorkspaceId && (
<>
{showOrganizationDropdown && <DropdownMenuSeparator />}
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
return setting.hidden ? null : (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
hidden={setting.hidden}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</div>
<DropdownMenuCheckboxItem
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
className="cursor-pointer">
<SettingsIcon className="mr-2 h-4 w-4" />
{t("common.settings")}
</DropdownMenuCheckboxItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -0,0 +1,60 @@
"use client";
import { OrganizationBreadcrumb } from "@/app/(app)/workspaces/[workspaceId]/components/organization-breadcrumb";
import { WorkspaceBreadcrumb } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface WorkspaceAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentWorkspaceId?: string;
currentWorkspaceName?: string; // Optional: for pages without context
isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isAccessControlAllowed: boolean;
isMembershipPending: boolean;
}
export const WorkspaceAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentWorkspaceId,
currentWorkspaceName,
isMultiOrgEnabled,
organizationWorkspacesLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMembershipPending,
}: WorkspaceAndOrgSwitchProps) => {
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentWorkspaceId={currentWorkspaceId}
isMultiOrgEnabled={isMultiOrgEnabled}
/>
{currentWorkspaceId && (
<WorkspaceBreadcrumb
currentWorkspaceId={currentWorkspaceId}
currentWorkspaceName={currentWorkspaceName}
currentOrganizationId={currentOrganizationId}
isOwnerOrManager={isOwnerOrManager}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={false}
isMembershipPending={isMembershipPending}
/>
)}
</BreadcrumbList>
</Breadcrumb>
);
};

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