Compare commits

..

199 Commits

Author SHA1 Message Date
Dhruwang Jariwala bad3b7a771 fix: (backport) prevent SSRF via redirect following in webhook delivery (#7877) (#7892) 2026-04-27 15:32:12 +05:30
Dhruwang Jariwala 007d99f6b8 fix: prevent Airtable integration crash when token expires (backport #7811) (#7873) 2026-04-27 15:32:03 +05:30
Dhruwang Jariwala 03b7dfefe4 fix: fixes sentry ref issue (backport #7776) (#7872) 2026-04-27 15:31:52 +05:30
Anshuman Pandey 9178558ba1 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 15:08:17 +05:30
Dhruwang Jariwala a65e6d9093 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 11:02:04 +05:30
Anshuman Pandey 592d36542f fix: fixes sentry ref issue (#7776) 2026-04-27 11:01:17 +05:30
Tiago 5ec8218666 fix: (backport) password hash visibility improvement (#7814) (#7833) 2026-04-24 14:33:26 +00:00
Tiago Farto e1a44817f2 fix: password hash visibility improvement
(cherry picked from commit 73ad130ece)
2026-04-24 13:10:40 +00:00
Dhruwang Jariwala 7f5b2bf69d fix: prevent split offline responses on restore (backport #7767) (#7777) 2026-04-20 12:00:34 +05:30
Dhruwang 60e7c7e8ee fix(surveys): prevent split offline responses on restore (backport #7767)
Backport of #7767 to release/4.9. Anchors displayId and responseId back
into saved survey progress as soon as they are created, recovers a
missing responseId from displayId on restore, and falls back to a
bootstrap create path that uses the full accumulated response state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:43:46 +05:30
Dhruwang Jariwala 7988d7775c fix: [backport] remove dark: variant classes from survey-ui to prevent host page style leakage (#7748) 2026-04-16 11:20:33 +05:30
Dhruwang Jariwala b7ede6c578 fix: prevent offline replay from dropping survey blocks after completion (#7744) 2026-04-15 22:00:29 +02:00
Bhagya Amarasinghe 8204a5c652 fix: restore legacy SSO auto-linking hotfix (#7728) 2026-04-13 20:42:33 +05:30
Anshuman Pandey e823e10f9a fix: backports missing posthog events fix (#7723) 2026-04-13 17:36:39 +05:30
Dhruwang Jariwala f5c3212b2c revert: enhance welcome card to support video uploads (backport #7712) (#7720)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 14:59:20 +05:30
Dhruwang Jariwala 2d66fc6987 fix: prevent TTC overcount for multi-question blocks (backport #7713) (#7719) 2026-04-13 14:40:35 +05:30
Dhruwang Jariwala 652970003d fix: validate "Other" option text on required questions and remove duplicate response entry (backport #7716) (#7717) 2026-04-13 12:27:08 +04:00
Dhruwang Jariwala a8b5e286b6 fix: only show beforeunload warning when offline support is active (backport #7715) (#7718) 2026-04-13 12:26:30 +04: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
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
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
Bhagya Amarasinghe 1064f68435 fix: support OTEL host config for envoy telemetry (#7692) 2026-04-09 07:25:52 +00:00
Anshuman Pandey 3d16e859c6 feat: custom posthog events (#7647) 2026-04-09 05:34:01 +00:00
Salim B af198c5632 docs: remove spurious left-overs (#7690) 2026-04-08 16:11:30 +00:00
Bhagya Amarasinghe a43ed2b25c feat: add envoy gateway helm bundle (#7686) 2026-04-08 07:34:47 +00: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 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
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
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
Bhagya Amarasinghe 0df5e26381 fix: handle license 403 as instance mismatch (#7458)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-12 10:46:44 +00:00
Matti Nannt 89bb3bcd84 chore: apply NCU minor upgrades fixups (#7460) 2026-03-12 10:44:18 +00:00
Harsh Bhat 30fdb72c09 feat: add PostHog analytics (#7454)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-12 09:53:14 +01:00
Matti Nannt cb58cf5825 fix: restrict selected entitlements during trial (#7456)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-12 08:10:23 +00:00
Johannes 99bd2ba256 feat: add reverse trial functionality (#7435)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-03-11 14:47:48 +00:00
Anshuman Pandey 9df423073f fix: zlib CVE (#7444) 2026-03-11 11:10:29 +00:00
Johannes 3e3c696972 feat: add trigger after time passed (#7452)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-11 10:12:31 +00:00
Anshuman Pandey cb41e2d344 fix: sets apps/web TS strict check to true (#7451) 2026-03-11 10:14:37 +01:00
Matti Nannt 1e19cca7d9 feat: implement cloud stripe billing sync and pricing revamp (#7309)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 16:04:23 +00:00
Johannes fa882dd4cc fix: improve survey validation error handling in SurveyMenuBar component (#7447) 2026-03-10 10:23:05 +00:00
Matti Nannt 0b82c6de77 feat: move multi-language surveys and workspace languages to AGPL (#7426)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-10 09:28:01 +00:00
Balázs Úr a944d7626e chore: use Unicode punctuation, remove contractions, make wording consistent (#7355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 07:06:30 +00:00
Balázs Úr d1b12dc228 fix: mark strings as translatable in survey editor (#7369)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:14:29 +00:00
Bhagya Amarasinghe 9f7d6038b1 docs: add CDN guidance for self-hosting (#7446)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:12:36 +00:00
Balázs Úr 1da92addd2 fix: Hungarian translations (#7434)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-09 12:31:24 +00:00
Dhruwang Jariwala 1e4aa5f54b fix: strip inline styles preserve target attr (#7441)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:09:51 +00:00
Anshuman Pandey 96f173c3b1 fix: overrides packages for CVE fixes (#7442) 2026-03-09 09:55:02 +00:00
Harsh Bhat 9c9e55fba6 docs: add keycloack docs (#7440) 2026-03-09 08:38:00 +00:00
Johannes 42541f86fd feat(navigation): add workflows section to main navigation and update… (#7392) 2026-03-08 18:13:38 +00:00
Matti Nannt 0ba469a73d fix: pin fast-xml-parser to 5.3.5 (#7436) 2026-03-06 20:20:34 +01:00
Matti Nannt afa192e5b9 chore: upgrade deps and Zod v4 migration (#7425)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 14:41:28 +01:00
Bhagya Amarasinghe 4860a9a5cf fix: helm template duplicate label key in migration-job (#7431)
Co-authored-by: Rob <178471500+rob-htl@users.noreply.github.com>
2026-03-06 11:48:07 +00:00
Chowdhury Tafsir Ahmed Siddiki af02ce9ea6 fix: display native language names in profile language selector (#7349)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 10:18:52 +00:00
Bhagya Amarasinghe fc1c91896a fix: add server-side SSRF validation for webhook URLs (#7414)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 07:36:49 +00:00
Balázs Úr f5c7dbdc71 fix: mark duplicated survey name as translatable (#7379)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:37:05 +00:00
Balázs Úr b88ea5cc66 fix: use proper plural forms (#7322)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:30:27 +00:00
bharath kumar f31085a9e7 fix(i18n): resolve duplicate Hungarian translations causing Career Development Survey creation to fail (#7410)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 05:39:05 +00:00
Dhruwang Jariwala 2ab0441404 fix: z-index for multi select question with dropdwon display type (#7420) 2026-03-06 04:56:39 +00:00
Dhruwang Jariwala 299ae81b21 chore: mls tweaks (#7416) 2026-03-05 14:55:45 +00:00
Bhagya Amarasinghe f73f13f16c perf: fix Prisma connection pool saturation from unbounded Promise.all fan-outs (#7404) 2026-03-05 14:35:40 +00:00
Matti Nannt e9bcbf6e4c fix: patch @isaacs/brace-expansion to 5.0.1 (#7424) 2026-03-05 13:35:48 +00:00
Matti Nannt 32eda35a71 chore: clean up stale turbo task config (#7423) 2026-03-05 11:49:24 +00:00
Dhruwang Jariwala 84999cddfd feat: danish support to surveys package (#7415) 2026-03-05 11:05:40 +00:00
Matti Nannt f0a0cf531a chore: clean up unused npm dependencies (#7417)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-05 10:48:13 +00:00
Matti Nannt f3e02fa466 chore: optimize monorepo build performance (#7419) 2026-03-05 10:18:54 +00:00
Dhruwang Jariwala f0a93ae092 fix: add Tailwind v3 config for Prettier in apps/web and packages/email (#7421) 2026-03-05 10:05:05 +00:00
Matti Nannt 1c922dfe2c chore: remove legacy post-checkout hook (#7418) 2026-03-05 08:14:19 +00:00
Bhagya Amarasinghe 33010fb6f5 fix: auto-save creates duplicate follow ups (#7413)
Co-authored-by: gulshank0 <gulshanbahadur002@gmail.com>
2026-03-05 00:44:29 +00:00
Matti Nannt d5fdacadd7 chore: update dependencies and fix build/lint/test regressions (#7403) 2026-03-03 17:03:03 +00:00
bharath kumar d939263472 fix(sdk): add userId length limit to mitigate DoS attack risk (#7378)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-03-03 10:10:01 +01:00
Dhruwang Jariwala e4aa66b067 fix: removed legacy response note traces (#7396) 2026-03-02 12:58:37 +00:00
Dhruwang Jariwala ffcc101ed9 chore: make productionBrowserSourceMaps conditional to decrease build time (#7400) 2026-03-02 09:49:00 +00:00
Balázs Úr 2740cd16b9 fix: delete confirmation dialog title translation (#7358)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-02 07:06:14 +00:00
Dhruwang Jariwala 7eb94f0bd5 fix: theme styling preview, option border color, and enable custom styling behavior (#7387)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-02 06:17:52 +00:00
Johannes 6dd2e707fe feat: display Formbricks version alongside organization ID in settings (#7363) 2026-03-02 05:54:23 +00:00
Matti Nannt 58d5de7d45 fix: resolve Dependabot Next.js deserialization alert (#7393) 2026-02-27 22:18:38 +01:00
Dhruwang Jariwala 7c3fa8b5ea fix: restore bullet points in survey preview and public survey (#7356) (#7360)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-27 18:24:15 +00:00
Harsh Bhat 2601169877 docs: add advanced CSS variable updates (#7389)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-27 17:19:22 +00:00
bharath kumar aecf85815a fix(js-core): use closest() fallback for nested click target matching (#7327)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-27 06:24:58 +00:00
Bhagya Amarasinghe c6ebaea989 fix: set success_action_status on S3 presigned POST to fix CORS on Ceph-based providers (#7362) 2026-02-26 10:26:49 +00:00
Bhagya Amarasinghe 68c1422733 fix: copy database package.json to Docker runner stage (#7371) 2026-02-26 10:25:28 +00:00
Dhruwang Jariwala 6942502baf fix: slack missing redirect uri (#7372) 2026-02-26 10:01:25 +00:00
Theodór Tómas a4bd217761 chore: update to zod 3.25.76 (#7366) 2026-02-26 05:17:20 +00:00
Bhagya Amarasinghe fee770358c perf(contacts): build segment WHERE clauses sequentially to prevent pool saturation (#7354)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-25 15:25:32 +00:00
Dhruwang Jariwala 44f8f80cac docs: clarify startAt is block-based, not question-based (#1404) (#7352)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 13:19:30 +00:00
Chowdhury Tafsir Ahmed Siddiki 858a7f7aa9 fix: replace toSorted in breadcrumb switchers for compatibility (#7325)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:29:31 +00:00
Gulshan ac40b90e81 fix: made "Filter" string translatable (#7301)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:28:51 +00:00
Balázs Úr aa21b4e442 fix: made Contact's page titles and table headers translatable (#7313)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 14:07:05 +00:00
Dhruwang Jariwala fa72296de5 fix: error state for multi select question (#7335) 2026-02-24 13:34:48 +00:00
Johannes 3776b31794 feat: add impressions tab and display data retrieval for surveys (#7266)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 11:00:58 +00:00
Bhagya Amarasinghe 5c7ea33fb0 feat: add pod disruption budget for helm chart (#7339) 2026-02-24 10:43:16 +00:00
Balázs Úr 33f60ce2be fix: button label on create attribute dialog (#7331)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 08:30:20 +00:00
Bhagya Amarasinghe c0386cea5a perf(contacts): batch segment evaluation queries into single transaction (#7333)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 08:26:46 +00:00
Anshuman Pandey 7cea53130c chore: adds webhook signing to test event (#7320) 2026-02-23 12:36:50 +00:00
Dhruwang Jariwala 0636989d67 fix: update test configuration to exclude .next directory from testing (#7334) 2026-02-23 11:33:17 +01:00
Anshuman Pandey 219883266c fix: add bool support (#7323) 2026-02-20 15:30:40 +00:00
Theodór Tómas 55fc2b2bc8 chore: removing i18n from pre-commit hook (#7318) 2026-02-20 10:48:44 +00:00
neila 6e4ef9a099 fix: make pretty URL paths accessible from public domain (#7264)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:40 +00:00
Chowdhury Tafsir Ahmed Siddiki ebf7d1e3a1 fix: prevent crash in NotificationSwitch via optional chaining (#7268)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-20 09:55:06 +00:00
Dhruwang Jariwala 998162bc48 fix: Google Sheets integration — token expiry & permission error handling (#7282) (#7285) 2026-02-20 08:56:24 +00:00
Anshuman Pandey 4fadc54b4e fix: fixes storage resolution issues (#7310) 2026-02-19 14:03:19 +00:00
Dhruwang Jariwala f4ac9a8292 fix: always validate only responseData fields in client/management APIs (#7292) (#7296)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 08:56:42 +00:00
Anshuman Pandey 7c8a7606b7 fix: fixes the no segment in draft surveys bug (#7290) 2026-02-19 08:16:18 +00:00
Anshuman Pandey 225217330b fix: adds dataType filter in bc code (#7294) 2026-02-19 07:47:58 +00:00
Dhruwang Jariwala 589c04a530 fix: allow CTA elements to proceed when marked required (#1415) (#7293)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 06:56:03 +00:00
Anshuman Pandey aa538a3a51 fix: better query in the backwards compatible code (#7288) 2026-02-18 13:00:19 +00:00
Anshuman Pandey 817e108ff5 docs: adds migration docs (#7281)
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
2026-02-17 17:01:46 +01:00
Theodór Tómas 33542d0c54 fix: default preview colors (#7277)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-17 11:28:58 +00:00
Matti Nannt f37d22f13d docs: align rate limiting docs with current code enforcement (#7267)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-17 07:42:53 +00:00
Anshuman Pandey 202ae903ac chore: makes rate limit config const (#7274) 2026-02-17 06:49:56 +00:00
Dhruwang Jariwala 6ab5cc367c fix: reduced default height of input (#7259) 2026-02-17 05:11:29 +00:00
Theodór Tómas 21559045ba fix: input placeholder color (#7265) 2026-02-17 05:11:01 +00:00
Theodór Tómas d7c57a7a48 fix: disabling cache in dev (#7269) 2026-02-17 04:44:22 +00:00
Chowdhury Tafsir Ahmed Siddiki 11b2ef4788 docs: remove stale 'coming soon' placeholders (#7254) 2026-02-16 13:21:12 +00:00
Theodór Tómas 6fefd51cce fix: suggest colors has better succes copy (#7258) 2026-02-16 13:18:46 +00:00
Theodór Tómas 65af826222 fix: matrix table preview (#7257)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-16 13:18:17 +00:00
Anshuman Pandey 12eb54c653 fix: fixes number being passed into string attribute (#7255) 2026-02-16 11:18:59 +00:00
Dhruwang Jariwala 5aa1427e64 fix: input combobx height (#7256) 2026-02-16 10:03:23 +00:00
1268 changed files with 55149 additions and 25271 deletions
+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
'''
+41 -1
View File
@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1 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 login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1 # EMAIL_AUTH_DISABLED=1
@@ -132,6 +138,31 @@ AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET= AZUREAD_CLIENT_SECRET=
AZUREAD_TENANT_ID= AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, gcp, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=gcp
# AI_MODEL=gemini-2.5-flash
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_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 # OpenID Connect (OIDC) configuration
# OIDC_CLIENT_ID= # OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET= # OIDC_CLIENT_SECRET=
@@ -150,6 +181,7 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET= NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables # Stripe Billing Variables
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
@@ -184,6 +216,14 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # 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) # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -230,4 +270,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here LINGO_API_KEY=your_api_key_here
@@ -285,12 +285,14 @@ runs:
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }} redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }} sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env: env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
- name: Sign GHCR image (GHCR only) - name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }} if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
+1
View File
@@ -92,3 +92,4 @@ jobs:
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
+20 -22
View File
@@ -6,19 +6,9 @@ permissions:
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push: push:
branches: branches:
- main - main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs: jobs:
validate-translations: validate-translations:
@@ -33,30 +23,38 @@ jobs:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 22.x node-version: 22.x
- name: Install pnpm - name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys - name: Validate translation keys
run: | if: steps.changes.outputs.translations == 'true'
echo "" run: pnpm run scan-translations
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Summary - name: Skip (no translation-related changes)
if: success() if: steps.changes.outputs.translations != 'true'
run: | run: echo "No translation-related files changed — skipping validation."
echo ""
echo "✅ Translation validation completed successfully!"
echo ""
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv .direnv
# Playwright # Playwright
/test-results/ **/test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
-2
View File
@@ -1,2 +0,0 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json
+10 -37
View File
@@ -1,40 +1,13 @@
# Load environment variables from .env files #!/usr/bin/env sh
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set elif command -v npm >/dev/null 2>&1; then
if [ -n "$LINGODOTDEV_API_KEY" ]; then npm exec --yes pnpm@10.32.1 lint-staged
echo "" elif command -v corepack >/dev/null 2>&1; then
echo "🌍 Running Lingo.dev translation workflow..." corepack pnpm lint-staged
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else else
echo "" echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set" echo "Install Node.js tooling or update your PATH, then retry the commit."
echo " (This is expected for community contributors)" exit 127
echo ""
fi fi
+8
View File
@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`. - 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. - 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 ## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment. - 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. 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 #### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment). 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 ## 👨‍💻 Development
### Prerequisites ### 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. 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>
+12 -17
View File
@@ -10,25 +10,20 @@
"build-storybook": "storybook build", "build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.0", "@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.1.11", "@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.1.11", "@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.1.11", "@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.1.11", "@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.1.18", "@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.53.0", "@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.2", "@vitejs/plugin-react": "5.1.4",
"esbuild": "0.25.12",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.1.11", "eslint-plugin-storybook": "10.2.17",
"prop-types": "15.8.1", "storybook": "10.2.17",
"storybook": "10.1.11",
"vite": "7.3.1", "vite": "7.3.1",
"@storybook/addon-docs": "10.1.11" "@storybook/addon-docs": "10.2.17"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};
+9 -4
View File
@@ -67,6 +67,7 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \ --mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \ --mount=type=secret,id=redis_url \
--mount=type=secret,id=sentry_auth_token \ --mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web... /tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# #
@@ -74,9 +75,10 @@ RUN --mount=type=secret,id=database_url \
# #
FROM base AS runner FROM base AS runner
# Update npm to latest, then create user # Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime # Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \ RUN apk update && apk upgrade --no-cache \
&& npm install --ignore-scripts -g npm@latest \
&& addgroup -S nextjs \ && addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs && adduser -S -u 1001 -G nextjs nextjs
@@ -120,8 +122,11 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2 COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2 RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid # Runtime migrations import uuid v7 from the database package, so copy the
RUN chmod -R 755 ./node_modules/uuid # database package's resolved install instead of the repo-root hoisted version.
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid \
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : ( ) : (
<div className="flex animate-pulse flex-col items-center space-y-4"> <div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10"> <span className="relative flex h-10 w-10">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span> <span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span> </span>
<p className="pt-4 text-sm font-medium text-slate-600"> <p className="pt-4 text-sm font-medium text-slate-600">
@@ -1,5 +1,6 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new Error(t("common.environment_not_found")); throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
} }
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const channel = project.config.channel || null; const channel = project.config.channel || null;
@@ -46,7 +47,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>
@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props) => { const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -2,6 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils"; import { replacePresetPlaceholders } from "./utils";
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
elements: [ elements: [
{ {
id: "q1", id: "q1",
type: "openText" as const, type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const, inputType: "text" as const,
headline: { default: "$[projectName] Question" }, headline: { default: "$[projectName] Question" },
subheader: { default: "" }, subheader: { default: "" },
required: false, required: false,
placeholder: { default: "" }, placeholder: { default: "" },
charLimit: 1000, charLimit: { enabled: true, max: 1000 },
}, },
], ],
}, },
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
}); });
test("getXMSurveyDefault returns default survey template", () => { test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key) => key) as TFunction; const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMSurveyDefault(tMock); const result = getXMSurveyDefault(tMock);
expect(result).toEqual({ expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
}); });
test("getXMTemplates returns all templates", () => { test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key) => key) as TFunction; const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);
expect(result).toHaveLength(6); expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => { test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => { const tMock = vi.fn(() => {
throw new Error("Test error"); throw new Error("Test error");
}) as TFunction; }) as unknown as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service"; import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
const t = await getTranslate(); const t = await getTranslate();
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
if (!environment) { if (!environment) {
throw new Error(t("common.environment_not_found")); throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
} }
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
const projects = await getUserProjects(session.user.id, organizationId); const projects = await getUserProjects(session.user.id, organizationId);
@@ -49,7 +50,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => { test("returns mapped teams", async () => {
const mockTeams = [ const mockTeams = [
{ id: "t1", name: "Team 1" }, { id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
{ id: "t2", name: "Team 2" }, { id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
]; ];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams); vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1"); const result = await getTeamsByOrganizationId("org1");
@@ -22,12 +22,10 @@ export const getTeamsByOrganizationId = reactCache(
}, },
}); });
const projectTeams = teams.map((team) => ({ return teams.map((team: TOrganizationTeam) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
})); }));
return projectTeams;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return ( return (
<aside <aside
className={cn( className={cn(
"w-sidebar-collapsed 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" "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("environments.formbricks_logo")} />
@@ -5,7 +5,10 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props) => { const LandingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
const Page = async (props) => { const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
@@ -26,7 +26,8 @@ const Page = async (props) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember } = getAccessFlags(membership?.role); const { isMember, isBilling } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
return ( return (
<div className="flex min-h-full min-w-full flex-row"> <div className="flex min-h-full min-w-full flex-row">
@@ -45,6 +46,8 @@ const Page = async (props) => {
isOwnerOrManager={false} isOwnerOrManager={false}
isAccessControlAllowed={false} isAccessControlAllowed={false}
isMember={isMember} isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]} environments={[]}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; 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 { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props) => { const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -22,7 +25,7 @@ const ProjectOnboardingLayout = async (props) => {
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { 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); const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -33,7 +36,7 @@ const ProjectOnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
} }
return ( return (
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -8,7 +9,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props) => { const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -25,11 +29,13 @@ const OnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`); return redirect(`/`);
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -0,0 +1,22 @@
import { getTranslate } from "@/lingodotdev/server";
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
interface SelectPlanOnboardingProps {
organizationId: string;
}
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
const t = await getTranslate();
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div>
);
};
@@ -0,0 +1,42 @@
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;
}>;
}
const Page = async (props: PlanPageProps) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
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} />;
};
export default Page;
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider> </FormProvider>
</div> </div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow"> <div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
{logoUrl && ( {logoUrl && (
<Image <Image
src={logoUrl} src={logoUrl}
@@ -239,11 +239,10 @@ export const ProjectSettings = ({
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline <SurveyInline
appUrl={publicDomain} appUrl={publicDomain}
isPreviewMode={true} isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)} survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling} styling={previewStyling}
isBrandingEnabled={false} isBrandingEnabled={false}
languageCode="default" languageCode="default"
@@ -251,7 +250,6 @@ export const ProjectSettings = ({
autoFocus={false} autoFocus={false}
/> />
</div> </div>
</div>
<CreateTeamModal <CreateTeamModal
open={createTeamModalOpen} open={createTeamModalOpen}
setOpen={setCreateTeamModalOpen} setOpen={setCreateTeamModalOpen}
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
@@ -42,10 +43,10 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) { if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found")); throw new ResourceNotFoundError(t("common.team"), null);
} }
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
@@ -69,7 +70,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
} }
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => { export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option) => { const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<OptionCard <OptionCard
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const ZOrganizationTeam = z.object({ export const ZOrganizationTeam = z.object({
id: z.string().cuid2(), id: z.cuid2(),
name: z.string(), name: z.string(),
}); });
@@ -1,8 +1,12 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props) => { const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -14,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new Error(t("common.environment_not_found")); throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
} }
return ( return (
@@ -6,15 +6,26 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti"; import { Confetti } from "@/modules/ui/components/confetti";
interface ConfirmationPageProps { const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
environmentId: string;
}
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => { export const ConfirmationPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setShowConfetti(true); setShowConfetti(true);
if (globalThis.window === undefined) {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
}
}, []); }, []);
return ( return (
@@ -30,7 +41,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
</p> </p>
</div> </div>
<Button asChild className="w-full justify-center"> <Button asChild className="w-full justify-center">
<Link href={`/environments/${environmentId}/settings/billing`}> <Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
{t("billing_confirmation.back_to_billing_overview")} {t("billing_confirmation.back_to_billing_overview")}
</Link> </Link>
</Button> </Button>
@@ -3,13 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const Page = async (props) => { const Page = async () => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
return ( return (
<PageContentWrapper> <PageContentWrapper>
<ConfirmationPage environmentId={environmentId?.toString()} /> <ConfirmationPage />
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -2,7 +2,11 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors"; import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project"; import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -10,7 +14,6 @@ import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission, getAccessControlPermission,
@@ -25,11 +28,8 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput, data: ZProjectUpdateInput,
}); });
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action( export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging( withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx; const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
@@ -50,10 +50,10 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
const organization = await getOrganization(organizationId); const organization = await getOrganization(organizationId);
if (!organization) { if (!organization) {
throw new Error("Organization not found"); throw new ResourceNotFoundError("Organization", organizationId);
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
@@ -61,7 +61,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
} }
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) { if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles"); throw new OperationNotAllowedError("You do not have permission to manage roles");
@@ -84,8 +84,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
ctx.auditLoggingCtx.projectId = project.id; ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project; ctx.auditLoggingCtx.newObject = project;
return project; return project;
} })
)
); );
const ZGetOrganizationsForSwitcherAction = z.object({ const ZGetOrganizationsForSwitcherAction = z.object({
@@ -97,7 +96,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher. * Called on-demand when user opens the organization switcher.
*/ */
export const getOrganizationsForSwitcherAction = authenticatedActionClient export const getOrganizationsForSwitcherAction = authenticatedActionClient
.schema(ZGetOrganizationsForSwitcherAction) .inputSchema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -122,7 +121,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher. * Called on-demand when user opens the project switcher.
*/ */
export const getProjectsForSwitcherAction = authenticatedActionClient export const getProjectsForSwitcherAction = authenticatedActionClient
.schema(ZGetProjectsForSwitcherAction) .inputSchema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -29,7 +30,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed, isAccessControlAllowed,
projectPermission, projectPermission,
license, license,
peopleCount,
responseCount, responseCount,
} = layoutData; } = layoutData;
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license; const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const isOwnerOrManager = isOwner || isManager; const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members // Validate that project permission exists for members
if (isMember && !projectPermission) { if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
return ( return (
@@ -52,7 +52,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner <LimitsReachedBanner
organization={organization} organization={organization}
environmentId={environment.id} environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount} responseCount={responseCount}
/> />
)} )}
@@ -76,6 +75,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role} membershipRole={membership.role}
publicDomain={publicDomain} publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/> />
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50"> <div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar <TopControlBar
@@ -2,40 +2,59 @@
import { import {
ArrowUpRightIcon, ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon, ChevronRightIcon,
Cog, Cog,
FoldersIcon,
Loader2,
LogOutIcon, LogOutIcon,
MessageCircle, MessageCircle,
PanelLeftCloseIcon, PanelLeftCloseIcon,
PanelLeftOpenIcon, PanelLeftOpenIcon,
PlusIcon,
RocketIcon, RocketIcon,
SettingsIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import {
getOrganizationsForSwitcherAction,
getProjectsForSwitcherAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils"; import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions"; import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import packageJson from "../../../../../package.json"; import packageJson from "../../../../../package.json";
interface NavigationProps { interface NavigationProps {
@@ -47,8 +66,31 @@ interface NavigationProps {
isDevelopment: boolean; isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
publicDomain: string; publicDomain: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
} }
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
if (pathname.includes("/settings/")) {
return false;
}
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
if (accountSettingsPattern.test(pathname)) {
return false;
}
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const MainNavigation = ({ export const MainNavigation = ({
environment, environment,
organization, organization,
@@ -58,6 +100,10 @@ export const MainNavigation = ({
isFormbricksCloud, isFormbricksCloud,
isDevelopment, isDevelopment,
publicDomain, publicDomain,
isMultiOrgEnabled,
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -67,7 +113,12 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState(""); const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole); const [isPending, startTransition] = useTransition();
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const disabledNavigationMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
@@ -104,6 +155,7 @@ export const MainNavigation = ({
icon: MessageCircle, icon: MessageCircle,
isActive: pathname?.includes("/surveys"), isActive: pathname?.includes("/surveys"),
isHidden: false, isHidden: false,
disabled: isMembershipPending || isBilling,
}, },
{ {
href: `/environments/${environment.id}/contacts`, href: `/environments/${environment.id}/contacts`,
@@ -113,15 +165,17 @@ export const MainNavigation = ({
pathname?.includes("/contacts") || pathname?.includes("/contacts") ||
pathname?.includes("/segments") || pathname?.includes("/segments") ||
pathname?.includes("/attributes"), pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
}, },
{ {
name: t("common.configuration"), name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`, href: `/environments/${environment.id}/workspace/general`,
icon: Cog, icon: Cog,
isActive: pathname?.includes("/project"), isActive: pathname?.includes("/workspace"),
disabled: isMembershipPending || isBilling,
}, },
], ],
[t, environment.id, pathname] [t, environment.id, pathname, isMembershipPending, isBilling]
); );
const dropdownNavigation = [ const dropdownNavigation = [
@@ -144,6 +198,183 @@ export const MainNavigation = ({
}, },
]; ];
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${environment.id}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${environment.id}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${environment.id}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${environment.id}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${environment.id}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${environment.id}/workspace/tags`,
},
];
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${environment.id}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environment.id}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environment.id}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environment.id}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environment.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
setWorkspaceLoadError(null);
try {
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setWorkspaceLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
);
} finally {
setIsLoadingProjects(false);
setHasInitializedProjects(true);
}
}, [organization.id, t]);
useEffect(() => {
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
return;
}
loadProjects();
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setOrganizationLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
);
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
!isOrganizationDropdownOpen ||
organizations.length > 0 ||
isLoadingOrganizations ||
organizationLoadError
) {
return;
}
loadOrganizations();
}, [
isOrganizationDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
useEffect(() => { useEffect(() => {
async function loadReleases() { async function loadReleases() {
const res = await getLatestStableFbReleaseAction(); const res = await getLatestStableFbReleaseAction();
@@ -159,7 +390,93 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases(); if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]); }, [isOwnerOrManager]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`; const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = isBilling
? getBillingFallbackPath(environment.id, isFormbricksCloud)
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
if (projectId === project.id) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleProjectCreate = () => {
if (!hasInitializedProjects || isLoadingProjects) {
return;
}
if (projects.length >= organizationProjectsLimit) {
setOpenProjectLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${environment.id}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${environment.id}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
};
const switcherTriggerClasses = cn(
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
return ( return (
<> <>
@@ -188,7 +505,7 @@ export const MainNavigation = ({
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none" "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 ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />
@@ -199,7 +516,6 @@ export const MainNavigation = ({
</div> </div>
{/* Main Nav Switch */} {/* Main Nav Switch */}
{!isBilling && (
<ul> <ul>
{mainNavigation.map( {mainNavigation.map(
(item) => (item) =>
@@ -210,13 +526,14 @@ export const MainNavigation = ({
isActive={item.isActive} isActive={item.isActive}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
isTextVisible={isTextVisible} isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}> linkText={item.name}>
<item.icon strokeWidth={1.5} /> <item.icon strokeWidth={1.5} />
</NavigationLink> </NavigationLink>
) )
)} )}
</ul> </ul>
)}
</div> </div>
<div> <div>
@@ -233,38 +550,217 @@ export const MainNavigation = ({
</Link> </Link>
)} )}
{/* User Switch */} {/* Trial Days Remaining */}
<div className="flex items-center"> {!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingProjects || isInitialProjectsLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects &&
!isInitialProjectsLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setProjects([]);
},
t("common.try_again")
)}
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === project.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleProjectCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
if (setting.hidden) return null;
return (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none"> className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<div <button
className={cn( type="button"
"flex cursor-pointer flex-row items-center gap-3", aria-label={isCollapsed ? t("common.account_settings") : undefined}
isCollapsed ? "justify-center px-2" : "px-4" className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
)}> <span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} /> <ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && ( {!isCollapsed && !isTextVisible && (
<> <>
<div <div className="grow overflow-hidden">
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p <p
title={user?.email} title={user?.email}
className={cn( className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
"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>} {user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p> </p>
<p className="text-sm text-slate-700">{t("common.account")}</p> <p className="text-sm text-slate-500">{t("common.account")}</p>
</div> </div>
<ChevronRightIcon <ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</> </>
)} )}
</div> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -273,8 +769,6 @@ export const MainNavigation = ({
sideOffset={10} sideOffset={10}
alignOffset={5} alignOffset={5}
align="end"> align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map((link) => (
<Link <Link
href={link.href} href={link.href}
@@ -288,7 +782,6 @@ export const MainNavigation = ({
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
))} ))}
{/* Logout */}
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`; const loginUrl = `${publicDomain}/auth/login`;
@@ -311,6 +804,28 @@ export const MainNavigation = ({
</div> </div>
</aside> </aside>
)} )}
{openProjectLimitModal && (
<ProjectLimitModal
open={openProjectLimitModal}
setOpen={setOpenProjectLimitModal}
buttons={projectLimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</> </>
); );
}; };
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps { interface NavigationLinkProps {
@@ -10,6 +11,8 @@ interface NavigationLinkProps {
children: React.ReactNode; children: React.ReactNode;
linkText: string; linkText: string;
isTextVisible: boolean; isTextVisible: boolean;
disabled?: boolean;
disabledMessage?: string;
} }
export const NavigationLink = ({ export const NavigationLink = ({
@@ -19,38 +22,26 @@ export const NavigationLink = ({
children, children,
linkText, linkText,
isTextVisible = true, isTextVisible = true,
disabled = false,
disabledMessage,
}: NavigationLinkProps) => { }: NavigationLinkProps) => {
const tooltipText = disabled ? disabledMessage || linkText : linkText;
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900"; const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass = const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out"; "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 ( return cn(baseClass, isActive ? activeClass : inactiveClass);
<> };
{isCollapsed ? (
<TooltipProvider delayDuration={0}> const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
<Tooltip> const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
<TooltipTrigger asChild>
<li const label = (
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 <span
className={cn( className={cn(
"ml-2 flex transition-opacity duration-100", "ml-2 flex transition-opacity duration-100",
@@ -58,7 +49,45 @@ export const NavigationLink = ({
)}> )}>
{linkText} {linkText}
</span> </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> </Link>
)}
</li> </li>
)} )}
</> </>
@@ -31,7 +31,8 @@ export const TopControlBar = ({
isAccessControlAllowed, isAccessControlAllowed,
membershipRole, membershipRole,
}: TopControlBarProps) => { }: TopControlBarProps) => {
const { isMember } = getAccessFlags(membershipRole); const { isMember, isBilling } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const { environment } = useEnvironment(); const { environment } = useEnvironment();
return ( return (
@@ -49,6 +50,8 @@ export const TopControlBar = ({
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isMember={isMember} isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
/> />
</div> </div>
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />
@@ -25,6 +25,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization } from "../context/environment-context"; import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps { interface OrganizationBreadcrumbProps {
@@ -35,6 +36,7 @@ interface OrganizationBreadcrumbProps {
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isMember: boolean; isMember: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
isMembershipPending: boolean;
} }
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => { const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
@@ -56,6 +58,7 @@ export const OrganizationBreadcrumb = ({
isFormbricksCloud, isFormbricksCloud,
isMember, isMember,
isOwnerOrManager, isOwnerOrManager,
isMembershipPending,
}: OrganizationBreadcrumbProps) => { }: OrganizationBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false); const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
@@ -81,7 +84,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort organizations by name // Sort organizations by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name)); const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted); setOrganizations(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors
@@ -142,7 +145,10 @@ export const OrganizationBreadcrumb = ({
id: "api-keys", id: "api-keys",
label: t("common.api_keys"), label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`, href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager, disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
{ {
id: "domain", id: "domain",
@@ -160,7 +166,11 @@ export const OrganizationBreadcrumb = ({
id: "enterprise", id: "enterprise",
label: t("common.enterprise_license"), label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`, href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isMember, hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
]; ];
@@ -242,14 +252,30 @@ export const OrganizationBreadcrumb = ({
{organizationSettings.map((setting) => { {organizationSettings.map((setting) => {
return setting.hidden ? null : ( return setting.hidden ? null : (
<div key={setting.id}>
{setting.disabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{setting.disabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)} checked={isActiveOrganizationSetting(pathname, setting.id)}
hidden={setting.hidden}
onClick={() => handleSettingChange(setting.href)} onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer"> className="cursor-pointer">
{setting.label} {setting.label}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
)}
</div>
); );
})} })}
</div> </div>
@@ -18,6 +18,8 @@ interface ProjectAndOrgSwitchProps {
isLicenseActive: boolean; isLicenseActive: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
isMember: boolean; isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
} }
@@ -35,6 +37,8 @@ export const ProjectAndOrgSwitch = ({
isOwnerOrManager, isOwnerOrManager,
isAccessControlAllowed, isAccessControlAllowed,
isMember, isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => { }: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId); const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development"; const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -50,6 +54,7 @@ export const ProjectAndOrgSwitch = ({
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isMember={isMember} isMember={isMember}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/> />
{currentProjectId && currentEnvironmentId && ( {currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb <ProjectBreadcrumb
@@ -63,6 +68,8 @@ export const ProjectAndOrgSwitch = ({
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb} isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/> />
)} )}
{showEnvironmentBreadcrumb && ( {showEnvironmentBreadcrumb && (
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react"; import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,6 +19,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt"; import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context"; import { useProject } from "../context/environment-context";
@@ -33,6 +34,8 @@ interface ProjectBreadcrumbProps {
currentEnvironmentId: string; currentEnvironmentId: string;
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean; isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
} }
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => { const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
@@ -56,6 +59,8 @@ export const ProjectBreadcrumb = ({
currentEnvironmentId, currentEnvironmentId,
isAccessControlAllowed, isAccessControlAllowed,
isEnvironmentBreadcrumbVisible, isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: ProjectBreadcrumbProps) => { }: ProjectBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false); const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
@@ -82,7 +87,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => { getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) { if (result?.data) {
// Sort projects by name // Sort projects by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name)); const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted); setProjects(sorted);
} else { } else {
// Handle server errors or validation errors // Handle server errors or validation errors
@@ -134,6 +139,10 @@ export const ProjectBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/workspace/tags`, href: `/environments/${currentEnvironmentId}/workspace/tags`,
}, },
]; ];
const areProjectSettingsDisabled = isMembershipPending || isBilling;
const projectSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentProject) { if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`; const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
@@ -198,7 +207,7 @@ export const ProjectBreadcrumb = ({
id="projectDropdownTrigger" id="projectDropdownTrigger"
asChild> asChild>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} /> <FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span> <span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />} {isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? ( {isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
@@ -211,7 +220,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuContent align="start" className="mt-2"> <DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500"> <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} /> <FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")} {t("common.choose_workspace")}
</div> </div>
{isLoadingProjects && ( {isLoadingProjects && (
@@ -247,7 +256,24 @@ export const ProjectBreadcrumb = ({
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
{isOwnerOrManager && ( {isMembershipPending || !isOwnerOrManager ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action")}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
onClick={handleAddProject} onClick={handleAddProject}
className="w-full cursor-pointer justify-between"> className="w-full cursor-pointer justify-between">
@@ -264,13 +290,30 @@ export const ProjectBreadcrumb = ({
{t("common.workspace_configuration")} {t("common.workspace_configuration")}
</div> </div>
{projectSettings.map((setting) => ( {projectSettings.map((setting) => (
<div key={setting.id}>
{areProjectSettingsDisabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{projectSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)} checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)} onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer"> className="cursor-pointer">
{setting.label} {setting.label}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
)}
</div>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props) => { const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId); const { session, organization } = await getEnvironmentAuth(params.environmentId);
@@ -12,11 +13,7 @@ const EnvironmentPage = async (props) => {
const { isBilling } = getAccessFlags(currentUserMembership?.role); const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) { if (isBilling) {
if (IS_FORMBRICKS_CLOUD) { return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
return redirect(`/environments/${params.environmentId}/settings/billing`);
} else {
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
}
} }
return redirect(`/environments/${params.environmentId}/surveys`); return redirect(`/environments/${params.environmentId}/surveys`);
@@ -1,10 +1,14 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props) => { const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -17,15 +21,15 @@ const AccountSettingsLayout = async (props) => {
]); ]);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), null);
} }
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
return <>{children}</>; return <>{children}</>;
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user"; import { ZUserNotificationSettings } from "@formbricks/types/user";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZUpdateNotificationSettingsAction = z.object({ const ZUpdateNotificationSettingsAction = z.object({
@@ -12,18 +11,9 @@ const ZUpdateNotificationSettingsAction = z.object({
}); });
export const updateNotificationSettingsAction = authenticatedActionClient export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction) .inputSchema(ZUpdateNotificationSettingsAction)
.action( .action(
withAuditLogging( withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id); const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings, notificationSettings: parsedInput.notificationSettings,
@@ -32,6 +22,5 @@ export const updateNotificationSettingsAction = authenticatedActionClient
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result; ctx.auditLoggingCtx.newObject = result;
return result; return result;
} })
)
); );
@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true; : notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => { const handleSwitchChange = async () => {
setIsLoading(true); setIsLoading(true);
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
]; ];
} }
} else { } else {
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] = updatedNotificationSettings[notificationType] = {
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId]; ...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
} }
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({ const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
) { ) {
switch (notificationType) { switch (notificationType) {
case "alert": case "alert":
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) { if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -16,8 +17,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings, notificationSettings: TUserNotificationSettings,
memberships: Membership[] memberships: Membership[]
): TUserNotificationSettings => { ): TUserNotificationSettings => {
const newNotificationSettings = { const newNotificationSettings: TUserNotificationSettings = {
alert: {}, alert: {} as Record<string, boolean>,
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
}; };
for (const membership of memberships) { for (const membership of memberships) {
@@ -26,7 +27,8 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) { for (const environment of project.environments) {
for (const survey of environment.surveys) { for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] = newNotificationSettings.alert[survey.id] =
notificationSettings[survey.id]?.responseFinished || (notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) || (notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key false; // check for legacy notification settings w/o "alerts" key
} }
@@ -136,24 +138,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships; return memberships;
}; };
const Page = async (props) => { const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const autoDisableNotificationType = searchParams["type"]; const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"]; const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]); const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
if (!memberships) { if (!memberships) {
throw new Error(t("common.membership_not_found")); throw new ResourceNotFoundError(t("common.membership"), null);
} }
if (user?.notificationSettings) { if (user?.notificationSettings) {
@@ -10,17 +10,18 @@ import {
getIsEmailUnique, getIsEmailUnique,
verifyUserPassword, verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return { return {
...(parsedInput.name && { name: parsedInput.name }), ...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }), ...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -63,17 +64,8 @@ async function handleEmailUpdate({
return payload; return payload;
} }
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action( export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging( withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id); const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput); let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload }); payload = await handleEmailUpdate({ ctx, parsedInput, payload });
@@ -89,24 +81,23 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
ctx.auditLoggingCtx.newObject = newObject; ctx.auditLoggingCtx.newObject = newObject;
return true; return true;
} })
)
); );
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging( withAuditLogging("passwordReset", "user", async ({ ctx }) => {
"passwordReset", if (PASSWORD_RESET_DISABLED) {
"user", throw new OperationNotAllowedError("Password reset is disabled");
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => { }
if (ctx.user.identityProvider !== "email") { if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user."); throw new OperationNotAllowedError("Password reset is not allowed for this user.");
} }
await sendForgotPasswordEmail(ctx.user); await requestPasswordReset(ctx.user, "profile");
ctx.auditLoggingCtx.userId = ctx.user.id; ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true }; return { success: true };
} })
)
); );
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils"; import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -116,10 +116,14 @@ export const EditProfileDetailsForm = ({
setShowModal(true); setShowModal(true);
} else { } else {
try { try {
await updateUserAction({ const result = await updateUserAction({
...data, ...data,
name: data.name.trim(), name: data.name.trim(),
}); });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.settings.profile.profile_updated_successfully")); toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload(); window.location.reload();
form.reset(data); form.reset(data);
@@ -198,7 +202,10 @@ export const EditProfileDetailsForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => ( render={({ field }) => {
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
return (
<FormItem className="mt-4"> <FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel> <FormLabel>{t("common.language")}</FormLabel>
<FormControl> <FormControl>
@@ -209,7 +216,15 @@ export const EditProfileDetailsForm = ({
variant="ghost" variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left"> className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"} {selectedLanguage ? (
<>
{selectedLanguage.label["en-US"]}
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
` (${selectedLanguage.label.native})`}
</>
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> <ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div> </div>
</Button> </Button>
@@ -218,12 +233,13 @@ export const EditProfileDetailsForm = ({
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700" className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start"> align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}> <DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => ( {sortedAppLanguages.map((lang) => (
<DropdownMenuRadioItem <DropdownMenuRadioItem
key={lang.code} key={lang.code}
value={lang.code} value={lang.code}
className="min-h-8 cursor-pointer"> className="min-h-8 cursor-pointer">
{lang.label["en-US"]} {lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
@@ -232,7 +248,8 @@ export const EditProfileDetailsForm = ({
</FormControl> </FormControl>
<FormError /> <FormError />
</FormItem> </FormItem>
)} );
}}
/> />
{isPasswordResetEnabled && ( {isPasswordResetEnabled && (
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password" aria-label="password"
aria-required="true" aria-required="true"
required required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
value={field.value} value={field.value}
onChange={(password) => field.onChange(password)} onChange={(password) => field.onChange(password)}
/> />
@@ -1,3 +1,4 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null; const user = session?.user ? await getUser(session.user.id) : null;
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email"; const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -60,7 +61,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[ buttons={[
{ {
text: IS_FORMBRICKS_CLOUD text: IS_FORMBRICKS_CLOUD
? t("common.start_free_trial") ? t("common.upgrade_plan")
: t("common.request_trial_license"), : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing` ? `/environments/${params.environmentId}/settings/billing`
@@ -22,8 +22,9 @@ export const OrganizationSettingsNavbar = ({
loading, loading,
}: OrganizationSettingsNavbarProps) => { }: OrganizationSettingsNavbarProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { isMember, isOwner } = getAccessFlags(membershipRole); const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember; const isOwnerOrManager = isOwner || isManager;
const isMembershipPending = membershipRole === undefined || loading;
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = [ const navigation = [
@@ -45,7 +46,10 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"), label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`, href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"), current: pathname?.includes("/api-keys"),
hidden: !isOwner, disabled: isMembershipPending || !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
{ {
id: "domain", id: "domain",
@@ -58,14 +62,18 @@ export const OrganizationSettingsNavbar = ({
id: "billing", id: "billing",
label: t("common.billing"), label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`, href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud || loading, hidden: !isFormbricksCloud,
current: pathname?.includes("/billing"), current: pathname?.includes("/billing"),
}, },
{ {
id: "enterprise", id: "enterprise",
label: t("common.enterprise_license"), label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`, href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isPricingDisabled, hidden: isFormbricksCloud,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
current: pathname?.includes("/enterprise"), current: pathname?.includes("/enterprise"),
}, },
]; ];
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils"; import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -25,10 +26,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
); );
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id); const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
@@ -0,0 +1,146 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};
@@ -6,22 +6,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions"; import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard"; import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps { interface EnterpriseLicenseStatusProps {
status: LicenseStatus; status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date; gracePeriodEnd?: Date;
environmentId: string; environmentId: string;
} }
const getBadgeConfig = ( const getBadgeConfig = (
status: LicenseStatus, status: TLicenseStatus,
t: TFunction t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => { ): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) { switch (status) {
@@ -29,6 +30,11 @@ const getBadgeConfig = (
return { type: "success", label: t("environments.settings.enterprise.license_status_active") }; return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired": case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") }; return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "instance_mismatch":
return {
type: "error",
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
};
case "unreachable": case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") }; return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license": case "invalid_license":
@@ -40,10 +46,12 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({ export const EnterpriseLicenseStatus = ({
status, status,
lastChecked,
gracePeriodEnd, gracePeriodEnd,
environmentId, environmentId,
}: EnterpriseLicenseStatusProps) => { }: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter(); const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false); const [isRechecking, setIsRechecking] = useState(false);
@@ -59,6 +67,8 @@ export const EnterpriseLicenseStatus = ({
if (result?.data) { if (result?.data) {
if (result.data.status === "unreachable") { if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable")); toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "instance_mismatch") {
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
} else if (result.data.status === "invalid_license") { } else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid")); toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else { } else {
@@ -86,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" /> <Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div> </div>
<Button <Button
type="button" type="button"
@@ -112,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small"> <Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal"> <AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", { {t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, { gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -128,6 +143,13 @@ export const EnterpriseLicenseStatus = ({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{status === "instance_mismatch" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_instance_mismatch_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500"> <p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "} {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a <a
@@ -10,8 +10,9 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
@@ -93,8 +94,10 @@ const Page = async (props) => {
/> />
</PageHeader> </PageHeader>
{hasLicense ? ( {hasLicense ? (
<>
<EnterpriseLicenseStatus <EnterpriseLicenseStatus
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"} status={licenseState.status}
lastChecked={licenseState.lastChecked}
gracePeriodEnd={ gracePeriodEnd={
licenseState.status === "unreachable" licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS) ? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
@@ -102,6 +105,8 @@ const Page = async (props) => {
} }
environmentId={params.environmentId} environmentId={params.environmentId}
/> />
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : ( ) : (
<div> <div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { updateOrganizationAISettingsAction } from "./actions";
import { ZOrganizationAISettingsInput } from "./schemas";
const mocks = vi.hoisted(() => ({
isInstanceAIConfigured: vi.fn(),
checkAuthorizationUpdated: vi.fn(),
deleteOrganization: vi.fn(),
getOrganization: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getTranslate: vi.fn(),
updateOrganization: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/lib/organization/service", () => ({
deleteOrganization: mocks.deleteOrganization,
getOrganization: mocks.getOrganization,
updateOrganization: mocks.updateOrganization,
}));
vi.mock("@/lib/ai/service", () => ({
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: mocks.getTranslate,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
const organizationId = "cm9gptbhg0000192zceq9ayuc";
describe("organization AI settings actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key
);
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
test("accepts AI toggle updates", () => {
expect(
ZOrganizationAISettingsInput.parse({
isAISmartToolsEnabled: true,
})
).toEqual({
isAISmartToolsEnabled: true,
});
});
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
const ctx = {
user: { id: "user_1", locale: "en-US" },
auditLoggingCtx: {},
};
const parsedInput = {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
};
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId,
access: [
{
type: "organization",
schema: ZOrganizationAISettingsInput,
data: parsedInput.data,
roles: ["owner", "manager"],
},
],
});
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
expect(ctx.auditLoggingCtx).toMatchObject({
organizationId,
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
test("propagates authorization failures so members cannot update AI settings", async () => {
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_member", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(AuthorizationError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("rejects enabling AI when the instance AI provider is not configured", async () => {
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(OperationNotAllowedError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("allows enabling AI when the instance configuration is valid", async () => {
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: true,
});
});
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: false,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: false,
});
});
});
@@ -2,14 +2,44 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getTranslate } from "@/lingodotdev/server";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({ const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId, organizationId: ZId,
@@ -17,7 +47,7 @@ const ZUpdateOrganizationNameAction = z.object({
}); });
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction) .inputSchema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging( withAuditLogging(
"updated", "updated",
@@ -27,26 +57,104 @@ export const updateOrganizationNameAction = authenticatedActionClient
parsedInput, parsedInput,
}: { }: {
ctx: AuthenticatedActionClientCtx; ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>; parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
}) => { }) =>
await checkAuthorizationUpdated({ updateOrganizationAction({
userId: ctx.user.id, ctx,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }), schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data, data: parsedInput.data,
roles: ["owner"], roles: ["owner"],
}, })
], )
);
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
const resolveOrganizationAISettings = ({
data,
organization,
}: {
data: z.infer<typeof ZOrganizationAISettingsInput>;
organization: TOrganizationAISettings;
}): TResolvedOrganizationAISettings => {
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
};
};
const assertOrganizationAISettingsUpdateAllowed = ({
isInstanceAIConfigured,
resolvedSettings,
t,
}: {
isInstanceAIConfigured: boolean;
resolvedSettings: TResolvedOrganizationAISettings;
t: Awaited<ReturnType<typeof getTranslate>>;
}) => {
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
}
};
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) => {
const t = await getTranslate(ctx.user.locale);
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
}
const resolvedSettings = resolveOrganizationAISettings({
data: parsedInput.data,
organization,
});
assertOrganizationAISettingsUpdateAllowed({
isInstanceAIConfigured: isInstanceAIConfigured(),
resolvedSettings,
t,
});
return updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationAISettingsInput,
data: parsedInput.data,
roles: ["owner", "manager"],
}); });
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;
} }
) )
); );
@@ -55,13 +163,15 @@ const ZDeleteOrganizationAction = z.object({
organizationId: ZId, organizationId: ZId,
}); });
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action( export const deleteOrganizationAction = authenticatedActionClient
withAuditLogging( .inputSchema(ZDeleteOrganizationAction)
"deleted", .action(
"organization", withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); if (!isMultiOrgEnabled) {
const t = await getTranslate(ctx.user.locale);
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
}
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -77,6 +187,5 @@ export const deleteOrganizationAction = authenticatedActionClient.schema(ZDelete
const oldObject = await getOrganization(parsedInput.organizationId); const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId); return await deleteOrganization(parsedInput.organizationId);
} })
) );
);
@@ -0,0 +1,118 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
}: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const aiEnablementState = getOrganizationAIEnablementState({
isInstanceConfigured: isInstanceAIConfigured,
});
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
toast.error(getFormattedErrorMessage(response));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
} finally {
setLoadingField(null);
}
};
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
<Alert variant="warning">
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
</Alert>
)}
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => { }: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const { t } = useTranslation(); const { t } = useTranslation();
const handleInputChange = (e) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value); setInputValue(e.target.value);
}; };
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage); toast.error(errorMessage);
} }
} catch (err) { } catch (err) {
toast.error(`Error: ${err.message}`); toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
} }
}; };
@@ -1,4 +1,5 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
@@ -9,7 +10,9 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip"; import { SecurityListTip } from "./components/SecurityListTip";
@@ -25,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null; const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled; const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role; const currentUserRole = currentUserMembership?.role;
@@ -59,6 +62,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role} membershipRole={currentUserMembership?.role}
/> />
</SettingsCard> </SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
/>
</SettingsCard>
<EmailCustomizationSettings <EmailCustomizationSettings
organization={organization} organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission} hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -81,7 +93,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" /> <IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -0,0 +1,13 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationAISettingsInput,
});
@@ -1,10 +1,11 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props) => { const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -17,15 +18,15 @@ const Layout = async (props) => {
]); ]);
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), null);
} }
if (!project) { if (!project) {
throw new Error(t("common.workspace_not_found")); throw new ResourceNotFoundError(t("common.workspace"), null);
} }
if (!session) { if (!session) {
throw new Error(t("common.session_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
return <>{children}</>; return <>{children}</>;
@@ -1,3 +1,3 @@
export const SettingsTitle = ({ title }) => { export const SettingsTitle = ({ title }: { title: string }) => {
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>; return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
}; };
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const Page = async (props) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`); return redirect(`/environments/${params.environmentId}/settings/profile`);
}; };
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -22,7 +23,7 @@ const ZGetResponsesAction = z.object({
}); });
export const getResponsesAction = authenticatedActionClient export const getResponsesAction = authenticatedActionClient
.schema(ZGetResponsesAction) .inputSchema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -56,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
}); });
export const getSurveySummaryAction = authenticatedActionClient export const getSurveySummaryAction = authenticatedActionClient
.schema(ZGetSurveySummaryAction) .inputSchema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -84,7 +85,7 @@ const ZGetResponseCountAction = z.object({
}); });
export const getResponseCountAction = authenticatedActionClient export const getResponseCountAction = authenticatedActionClient
.schema(ZGetResponseCountAction) .inputSchema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -106,3 +107,31 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria); return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
}); });
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.inputSchema(ZGetDisplaysWithContactAction)
.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 getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = { type Props = {
@@ -14,10 +15,11 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId); const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) { if (session) {
return { return {
title: `${responseCount} Responses | ${survey?.name} Results`, title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
}; };
} }
return { return {
@@ -25,7 +27,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
}; };
}; };
const SurveyLayout = async ({ children }) => { const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>; return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
}; };
@@ -29,6 +29,7 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils"; import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -96,8 +97,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0; const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns // Generate columns
const columns = useMemo( const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn), () => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn] [survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
); );
// Save settings to localStorage when they change // Save settings to localStorage when they change
@@ -201,15 +202,21 @@ export const ResponseTable = ({
}; };
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => { const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false }); const result = await deleteResponseAction({
responseId,
decrementQuotas: params?.decrementQuotas ?? false,
});
if (result?.serverError) {
throw new Error(getFormattedErrorMessage(result));
}
}; };
// Handle downloading selected responses // Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => { const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
try { try {
const downloadResponse = await getResponsesDownloadUrlAction({ const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id, surveyId: survey.id,
format: format, format,
filterCriteria: { responseIds }, filterCriteria: { responseIds },
}); });
@@ -300,7 +307,6 @@ export const ResponseTable = ({
<DataTableSettingsModal <DataTableSettingsModal
open={isTableSettingsModalOpen} open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen} setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table} table={table}
columnOrder={columnOrder} columnOrder={columnOrder}
handleDragEnd={handleDragEnd} handleDragEnd={handleDragEnd}
@@ -5,13 +5,14 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react"; import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses"; import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime"; import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -34,6 +35,7 @@ const getElementColumnsData = (
element: TSurveyElement, element: TSurveyElement,
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
locale: TUserLocale,
t: TFunction t: TFunction
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t); const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -41,7 +43,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"]; const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers // Helper function to create consistent column headers
const createElementHeader = (elementType: string, headline: string, suffix?: string) => { const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline; const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => ( const ElementHeader = () => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -167,6 +169,7 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -218,6 +221,7 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -232,7 +236,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = []; const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => { METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label]; const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
metadataColumns.push({ metadataColumns.push({
accessorKey: "METADATA_" + label, accessorKey: "METADATA_" + label,
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
isReadOnly: boolean, isReadOnly: boolean,
locale: TUserLocale,
t: TFunction, t: TFunction,
showQuotasColumn: boolean showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks); const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t)); const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = { const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt", accessorKey: "createdAt",
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
size: 200, size: 200,
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.createdAt); const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>; return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
}, },
}; };
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -38,7 +39,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source", "environments.surveys.responses.source": "Source",
}; };
return translations[key] || key; return translations[key] || key;
}); }) as unknown as TFunction;
describe("getAddressFieldLabel", () => { describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => { test("returns correct label for addressLine1", () => {
@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"]; const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"]; export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => { export const getMetadataValue = (
if (userAgentFields.includes(label)) { meta: TResponseMeta,
return meta.userAgent?.[label]; label: (typeof METADATA_FIELDS)[number]
): string | undefined => {
switch (label) {
case "browser":
return meta.userAgent?.browser;
case "os":
return meta.userAgent?.os;
case "device":
return meta.userAgent?.device;
case "action":
return meta.action;
case "country":
return meta.country;
case "source":
return meta.source;
case "url":
return meta.url;
} }
return meta[label];
}; };
@@ -1,3 +1,4 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -17,31 +17,30 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => { const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([ const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId), getSurvey(params.surveyId),
getUser(session.user.id), getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId), getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(), getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId), getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]); ]);
if (!survey) { if (!survey) {
throw new Error(t("common.survey_not_found")); throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
} }
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new AuthenticationError(t("common.not_authenticated"));
} }
if (!organization) { if (!organization) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), null);
} }
const segments = isContactsEnabled ? await getSegments(params.environmentId) : []; const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -50,10 +49,10 @@ const Page = async (props) => {
const organizationBilling = await getOrganizationBilling(organization.id); const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) { if (!organizationBilling) {
throw new Error(t("common.organization_not_found")); throw new ResourceNotFoundError(t("common.organization"), organization.id);
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan); const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : []; const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch // Fetch initial responses on the server to prevent duplicate client-side fetch
@@ -86,7 +85,7 @@ const Page = async (props) => {
environmentTags={tags} environmentTags={tags}
user={user} user={user}
responsesPerPage={RESPONSES_PER_PAGE} responsesPerPage={RESPONSES_PER_PAGE}
locale={locale} locale={user.locale}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
quotas={quotas} quotas={quotas}
@@ -7,7 +7,6 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion"; import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -22,7 +21,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
}); });
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.schema(ZSendEmbedSurveyPreviewEmailAction) .inputSchema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId); const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -65,24 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({ const ZResetSurveyAction = z.object({
surveyId: ZId, surveyId: ZId,
organizationId: ZId,
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging( withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
"updated", const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
"survey", const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
@@ -91,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAc
{ {
type: "projectTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
projectId: parsedInput.projectId, projectId,
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null; ctx.auditLoggingCtx.oldObject = null;
@@ -114,8 +106,7 @@ export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAc
deletedResponsesCount: deletedResponsesCount, deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount, deletedDisplaysCount: deletedDisplaysCount,
}; };
} })
)
); );
const ZGetEmailHtmlAction = z.object({ const ZGetEmailHtmlAction = z.object({
@@ -123,7 +114,7 @@ const ZGetEmailHtmlAction = z.object({
}); });
export const getEmailHtmlAction = authenticatedActionClient export const getEmailHtmlAction = authenticatedActionClient
.schema(ZGetEmailHtmlAction) .inputSchema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -152,9 +143,10 @@ const ZGeneratePersonalLinksAction = z.object({
}); });
export const generatePersonalLinksAction = authenticatedActionClient export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction) .inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled(); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) { if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment"); throw new OperationNotAllowedError("Contacts are not enabled for this environment");
} }
@@ -231,7 +223,7 @@ const ZUpdateSingleUseLinksAction = z.object({
}); });
export const updateSingleUseLinksAction = authenticatedActionClient export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction) .inputSchema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -30,8 +30,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.booked.count}{" "} {t("common.count_responses", { count: elementSummary.booked.count })}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -47,8 +46,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.skipped.count}{" "} {t("common.count_responses", { count: elementSummary.skipped.count })}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")} {t("common.count_responses", { count: summaryItem.count })}
</p> </p>
</div> </div>
<div className="group-hover:opacity-80"> <div className="group-hover:opacity-80">
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime"; import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
}; };
const renderResponseValue = (value: string) => { const renderResponseValue = (value: string) => {
const parsedDate = new Date(value); const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const formattedDate = isNaN(parsedDate.getTime()) return (
? `${t("common.invalid_date")}(${value})` formattedDate ??
: formatDateWithOrdinal(parsedDate); t("common.invalid_date_with_value", {
value,
return formattedDate; })
);
}; };
return ( return (
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => ( elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div <div
key={response.id} key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base"> className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
<div className="pl-4 md:pl-6"> <div className="pl-4 md:pl-6">
{response.contact ? ( {response.contact ? (
<Link <Link
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} {timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div> </div>
</div> </div>
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && ( {showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.responseCount} ${t("common.responses")}`} {t("common.count_responses", { count: elementSummary.responseCount })}
</div> </div>
)} )}
{additionalInfo} {additionalInfo}
@@ -41,8 +41,7 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div> </div>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{elementSummary.responseCount}{" "} {t("common.count_responses", { count: elementSummary.responseCount })}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div> </div>
</div> </div>
</div> </div>
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) { if (label) {
return label; return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) { } else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`; return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
} }
return ""; return "";
}; };
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}> )}>
<button <button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }} style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline" className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, elementSummary.element.id,
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? ( elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`} {t("common.count_selections", { count: elementSummary.selectionCount })}
</div> </div>
) : undefined ) : undefined
} }
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")} {t("common.count_selections", { count: result.count })}
</p> </p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%
@@ -60,7 +60,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}, },
}; };
const filter = filters[group]; const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
group
];
if (filter) { if (filter) {
setFilter( setFilter(
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4"> <TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base"> <div className="space-y-5 text-sm md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( {(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
key={group} key={group}
@@ -123,8 +125,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary[group]?.count}{" "} {t("common.count_responses", { count: elementSummary[group]?.count })}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar <ProgressBar
@@ -158,7 +159,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}> }>
<div className="flex h-32 w-full flex-col items-center justify-end"> <div className="flex h-32 w-full flex-col items-center justify-end">
<div <div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110" className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
style={{ style={{
height: `${Math.max(choice.percentage, 2)}%`, height: `${Math.max(choice.percentage, 2)}%`,
opacity, opacity,
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? ( elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`} {t("common.count_selections", { count: elementSummary.selectionCount })}
</div> </div>
) : undefined ) : undefined
} }
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")} {t("common.count_selections", { count: result.count })}
</p> </p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
) )
}> }>
<div <div
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`} className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }} style={{ opacity }}
/> />
</ClickableBarSegment> </ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")} {t("common.count_responses", { count: result.count })}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,8 +215,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2"> <div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p> <p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.dismissed.count}{" "} {t("common.count_responses", { count: elementSummary.dismissed.count })}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
</div> </div>
@@ -0,0 +1,125 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};
@@ -10,12 +10,12 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"]; surveySummary: TSurveySummary["meta"];
quotasCount: number; quotasCount: number;
isLoading: boolean; isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined; tab: "dropOffs" | "quotas" | "impressions" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>; setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
} }
const formatTime = (ttc) => { const formatTime = (ttc: number) => {
const seconds = ttc / 1000; const seconds = ttc / 1000;
let formattedValue; let formattedValue;
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation(); const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount; const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas") => { const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
const change = tab === val ? undefined : val; const change = tab === val ? undefined : val;
setTab(change); setTab(change);
}; };
@@ -65,12 +65,16 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`, `grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6" isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}> )}>
<StatCard <InteractiveCard
key="impressions"
tab="impressions"
label={t("environments.surveys.summary.impressions")} label={t("environments.surveys.summary.impressions")}
percentage={null} percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount} value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")} tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading} isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/> />
<StatCard <StatCard
label={t("environments.surveys.summary.starts")} label={t("environments.surveys.summary.starts")}
@@ -103,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")} label={t("environments.surveys.summary.time_to_complete")}
percentage={null} percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`} value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_tooltip")} tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading} isLoading={isLoading}
/> />
@@ -1,21 +1,31 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; import {
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop"; import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs"; import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter"; import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys"; import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary"; import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList"; import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata"; import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = { const defaultSurveySummary: TSurveySummary = {
meta: { meta: {
completedPercentage: 0, completedPercentage: 0,
@@ -51,17 +61,76 @@ export const SummaryPage = ({
initialSurveySummary, initialSurveySummary,
isQuotasAllowed, isQuotasAllowed,
}: SummaryPageProps) => { }: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>( const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary initialSurveySummary || defaultSurveySummary
); );
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined); const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary); const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter(); const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data // Only fetch data when filters change or when there's no initial data
useEffect(() => { useEffect(() => {
// If we have initial data and no filters are applied, don't fetch // If we have initial data and no filters are applied, don't fetch
@@ -121,6 +190,18 @@ export const SummaryPage = ({
setTab={setTab} setTab={setTab}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
/> />
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />} {tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />} {isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment(); const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true); setIsResetting(true);
const result = await resetSurveyAction({ const result = await resetSurveyAction({
surveyId: survey.id, surveyId: survey.id,
organizationId: organizationId,
projectId: project.id, projectId: project.id,
}); });
if (result?.data) { if (result?.data) {
@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card"; import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps { interface InteractiveCardProps {
tab: "dropOffs" | "quotas"; tab: "dropOffs" | "quotas" | "impressions";
label: string; label: string;
percentage: number; percentage: number | null;
value: React.ReactNode; value: React.ReactNode;
tooltipText: string; tooltipText: string;
isLoading: boolean; isLoading: boolean;
@@ -75,17 +75,7 @@ export const ShareSurveyModal = ({
const [showView, setShowView] = useState<ModalView>(modalView); const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user; const { email } = user;
const { t } = useTranslation(); const { t } = useTranslation();
const linkTabs: { const linkTabs = useMemo(() => {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const tabs = [ const tabs = [
{ {
id: ShareViaType.ANON_LINKS, id: ShareViaType.ANON_LINKS,
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
}, },
{ {
title: t("environments.surveys.share.anonymous_links.custom_start_point"), title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question", href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
}, },
]} ]}
/> />
@@ -47,6 +47,7 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
pageView: t("environments.actions.page_view"), pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"), exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"), fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
pageDwell: t("environments.actions.time_on_page"),
}); });
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => { const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}> <div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel> <FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3"> <div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600"> <pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{projectCustomScripts} {projectCustomScripts}
</pre> </pre>
</div> </div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8} rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")} placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn( className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" "flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)} )}
{...field} {...field}
disabled={isReadOnly} disabled={isReadOnly}
@@ -163,9 +163,10 @@ export const PersonalLinksTab = ({
<UpgradePrompt <UpgradePrompt
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")} title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")} description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
feature="personal_links"
buttons={[ buttons={[
{ {
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"), text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing` ? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license", : "https://formbricks.com/upgrade-self-hosting-license",
@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
} }
} }
} catch (error) { } catch (error) {
logger.error("Failed to generate QR code:", error); logger.error(error as Error, "Failed to generate QR code");
setHasError(true); setHasError(true);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
downloadInstance.download({ name: "survey-qr-code", extension: "png" }); downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon")); toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) { } catch (error) {
logger.error("Failed to download QR code:", error); logger.error(error as Error, "Failed to download QR code");
toast.error(t("environments.surveys.summary.qr_code_download_failed")); toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);
@@ -4,6 +4,10 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import {
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
@@ -13,9 +17,9 @@ interface SuccessViewProps {
publicDomain: string; publicDomain: string;
setSurveyUrl: (url: string) => void; setSurveyUrl: (url: string) => void;
user: TUser; user: TUser;
tabs: { id: string; label: string; icon: React.ElementType }[]; tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void; handleViewChange: (view: "start" | "share") => void;
handleEmbedViewWithTab: (tabId: string) => void; handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
isReadOnly: boolean; isReadOnly: boolean;
} }
@@ -66,7 +70,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8"> className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" /> <UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")} {t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} /> <Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button> </button>
<Link <Link
href={`/environments/${environmentId}/settings/notifications`} href={`/environments/${environmentId}/settings/notifications`}

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