Compare commits

..

198 Commits

Author SHA1 Message Date
Tiago Farto a20bb0bddd chore: address PR comments (nomenclature consistency) 2026-05-14 13:14:26 +00:00
Tiago Farto 674a43abb4 chore: auth redirect login 2026-05-14 12:25:17 +00:00
Tiago Farto 465654a483 chore: renamed env var, additional checks 2026-05-14 11:47:38 +00:00
Tiago Farto fab93c92bf chore: SSO deletion workflow simplification 2026-05-14 11:26:45 +00:00
Bhagya Amarasinghe 96b08fbe23 fix: single-use survey restriction bypass (#7972) 2026-05-14 09:11:40 +00:00
Johannes 4eba194935 fix: clarify signup product updates opt-in (#7994)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-14 07:37:19 +00:00
Dhruwang Jariwala 9d8cf5e0f7 fix(security): reject client-supplied emailVerificationDisabled in signup (ENG-816) (#7993) 2026-05-14 06:47:25 +00:00
Dhruwang Jariwala 3c6f6d83ea fix: Hungarian translation polish (ENG-935) (#8000) 2026-05-14 05:40:03 +00:00
Matti Nannt 1380c81bff fix: patch security dependency vulnerabilities for main (#7990)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:59:09 +00:00
Bhagya Amarasinghe 535c111860 fix: improve file upload storage errors (#7978) 2026-05-13 09:25:04 +00:00
Tiago 676e31c433 fix: scope org-only v1 API key auth (#7961) 2026-05-13 07:59:49 +00:00
Dhruwang Jariwala 88f17380e1 fix(i18n): replace fragmented translation concatenations with complete sentences (ENG-706) (#7969)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:42:40 +00:00
Tiago 103775b3b1 fix: validate contact_id (#7984) 2026-05-13 06:28:25 +00:00
Harsh Bhat 3005c44c49 chore: consolidate CE enterprise trial license links to a single form… (#7929) 2026-05-12 14:27:49 +00:00
Harsh Bhat 4d03ba2ff7 docs: add docs for pretty url (#7932) 2026-05-12 14:26:11 +00:00
Tiago bc25c482ad chore: fix broken impressions count (#7975) 2026-05-12 10:32:45 +00:00
dependabot[bot] b08f7e4ad9 chore(deps): bump the npm_and_yarn group across 2 directories with 2 updates (#7977)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-05-12 06:44:08 +00:00
Anshuman Pandey 89a8266ebe fix: fixes webhook ssrf toctou issue (#7954) 2026-05-12 06:03:15 +00:00
Tiago cad10b8810 chore: workspace delete confirmation dialog (#7958) 2026-05-11 10:14:02 +00:00
Harsh Bhat 1d18b5cb83 docs: fix env var names and add missing entries to reference table (#7964)
Co-authored-by: Harsh Bhat <harsh@formbricks.com>
2026-05-11 09:22:34 +02:00
Tiago 69ead97965 fix: sso account deletion password check (#7930) 2026-05-08 12:07:58 +00:00
Dhruwang Jariwala 7e2c439325 feat: add PostHog group analytics and feature events (#7914)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
2026-05-07 07:49:27 +00:00
Javi Aguilar a2177eec96 fix: survey modal accessibility issues by using a focus trap (#7939) 2026-05-07 06:14:06 +00:00
Javi Aguilar 255c97854f fix: survey runtime accessibility for keyboard controls (#7927) 2026-05-06 10:21:42 +00:00
Matti Nannt d103499496 fix(security): strip sensitive survey and segment metadata from public client API (#7931)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-06 09:54:15 +00:00
Matti Nannt b863238f15 refactor: rename gethasNoOrganizations to getHasNoOrganizations (#7940)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 05:03:02 +00:00
Johannes 28280899ea fix: recover incomplete initial setup (#7912) 2026-05-05 14:28:23 +00:00
Matti Nannt bc63870289 feat: add Linear Releases integration to CI pipeline (#7921)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:17:48 +00:00
Javi Aguilar 9a04e95d15 fix: cal and open text fields a11y semantic improvements (#7936) 2026-05-05 12:31:09 +00:00
Bhagya Amarasinghe 9d9f38515d fix: omit replicas when HPA is enabled (#7934) 2026-05-05 10:32:16 +00:00
Tiago fae00f6a82 fix: outlook preview (#7803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-04 15:10:14 +00:00
Anshuman Pandey a274c444ad fix: reject SSO auto-provisioning when AUTH_SSO_DEFAULT_TEAM_ID is missing (#7926) 2026-05-04 10:57:44 +00:00
Anshuman Pandey 5fae207cd7 fix: duplicate action class name error (#7919) 2026-04-30 09:17:09 +00:00
Johannes 654539d320 fix: removed dead menu item & theme import (#7909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 08:09:40 +00:00
Johannes 44aac89d41 fix: return generic credentials error for SSO-only accounts (#7911)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-30 08:09:34 +00:00
Johannes e0250b2a58 fix: include partial response in trigger description (#7908)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 06:27:48 +00:00
Labeeb a8d6cd8a9f fix: 7817 use fully translated inactive survey headings (#7836) 2026-04-30 04:46:13 +00:00
Bhagya Amarasinghe f79fe1490e feat: add PostHog experiment wrappers (#7899) 2026-04-29 08:07:11 +00:00
Dhruwang Jariwala 5cfbc671c5 fix: allow back navigation to prefilled questions in email embed surveys (#7900) 2026-04-29 08:06:08 +00:00
Tiago e6e9419b93 fix: require step-up authorization for account deletion (#7901)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-28 12:46:27 +00:00
dependabot[bot] 90eb78e571 chore(deps): bump the npm_and_yarn group across 5 directories with 2 updates (#7834)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-28 12:01:12 +02:00
Johannes 991866f549 docs: document option ID prefilling (#7893)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-28 08:17:05 +00:00
Bhagya Amarasinghe 6d1b3475d4 fix(survey-list): reduce v3 overview query cost (#7812) 2026-04-27 16:53:53 +00:00
Johannes 5e967a2b67 fix: use app.formbricks.com in v1 API docs (#7894)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-27 13:19:12 +00:00
Johannes 23a8fd6a47 fix: replace organization name placeholder example (#7832)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-27 12:49:16 +00:00
Dhruwang Jariwala 0686eb3cbb feat: add refetch button to refresh responses table (#7808)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-27 10:10:53 +00:00
Anshuman Pandey 6d9ab315c2 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 08:50:21 +00:00
Tiago 4128731c5f fix: password hash visibility improvement (#7814)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-24 18:16:59 +00:00
Matti Nannt ef96426ca0 chore(security): dependency audit — reduce attack surface & resolve all vulnerabilities (#7801)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:32:32 +02:00
Dhruwang Jariwala ce1dbe8b00 fix: apply plan changes immediately for non-standard plans (#7807) 2026-04-24 07:38:33 +00:00
Dhruwang Jariwala 444f043140 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:36:10 +00:00
Dhruwang Jariwala 2d32c0d671 feat: add iframe preview to website embed tab (#7791)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 06:36:55 +00:00
Dhruwang Jariwala 8dc70a5e30 fix: prevent survey widget CSS from polluting host page styles (#7805)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:44 +00:00
Aryan Ghugare 3e4e55fbf1 fix: prevent bypass of single-use survey restriction via v1 API (#7735)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 09:48:18 +00:00
Tiago fcfedd6e15 feat: replace minio with rustfs (#7742) 2026-04-22 08:07:38 +00:00
Tiago 6c4342690f fix: harden legacy SSO relinking (#7755) 2026-04-22 07:35:23 +00:00
arasucar b8c361fcf3 refactor: use context instead of prop drilling in survey analysis components (#6223) (#7754)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 06:00:13 +00:00
Matti Nannt 8771a0ec91 fix: lodash vulnerability (#7800) 2026-04-22 05:57:08 +00:00
Bhagya Amarasinghe fc33c52133 fix: patch protobufjs transitive vulnerabilities (#7790) 2026-04-21 09:59:12 +00:00
Balázs Úr 75cf9293b1 fix: Hungarian translation (#7752) 2026-04-21 08:41:53 +00:00
Serhat e489c6a346 feat: Add Turkish (tr) translations (#7645)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-20 12:51:25 +00:00
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
Dhruwang Jariwala 60bd5cbeff fix: prevent environment ID leak in API error responses (#7753)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:38:32 +00:00
Dhruwang Jariwala b6a3a15379 fix: make other option input field mandatory when sole selection (#7751)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:06:00 +00:00
Johannes c68f214eff fix: keep sidebar switcher icons round with long labels (#7756)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 08:04:10 +00:00
Harsh Bhat c90ee84483 chore: Add survey to formbricks docs (#7746) 2026-04-16 12:13:55 +00:00
Dhruwang Jariwala dc1ee72594 chore: translation management revamp (scope 1) (#7733)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-16 11:18:48 +00:00
Dhruwang Jariwala 924132287e fix: connect rating/NPS scale labels to label styling settings (#7738)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:59:59 +00:00
Dhruwang Jariwala e6f347aa07 fix: remove dark: variant classes from survey-ui to prevent host page style leakage (#7747) 2026-04-16 05:50:46 +00:00
Dhruwang Jariwala 367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Marius 0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
Dhruwang Jariwala 439dd0b44e fix: add loading skeleton for responses page (#7700)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-13 16:56:20 +00:00
Anshuman Pandey 2556f5e15d fix: add missing PostHog events (#7722)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:57:12 +00:00
Johannes cc0eec3bf0 feat: add auto-progress mode for rating and NPS surveys (#7709)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-13 11:22:50 +00:00
Johannes 4b009a8eb4 revert: enhance welcome card to support video uploads (#7712)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 08:17:05 +00:00
Johannes 2aaddf7306 fix: prevent TTC overcount for multi-question blocks (#7713)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-13 07:56:40 +00:00
Dhruwang Jariwala fb5d6145d0 fix: only show beforeunload warning when offline support is active (#7715) 2026-04-13 07:19:57 +00:00
Dhruwang Jariwala 59310bac93 fix: validate "Other" option text on required questions and remove duplicate response entry (#7716) 2026-04-13 07:05:08 +00:00
Dhruwang Jariwala 322f0be197 fix: improve restricted ID validation toast with i18n support (#7703)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-12 06:18:13 +00:00
Manuel Delgado 1a02f91afd fix(api): return 409 Conflict instead of 500 when creating user with duplicate email (#7675)
Co-authored-by: Tiago Farto <tiago@formbricks.com>
2026-04-10 14:28:17 +00:00
Tiago cc22ccb22d chore: Harden SSO account linking for existing email-based accounts (#7702) 2026-04-10 14:19:21 +00:00
Tiago 12763f0ef6 fix: Dutch translations for link survey footer (Privacy Policy, Imprint, Report Survey) (#7707) 2026-04-10 13:42:15 +00:00
Dhruwang Jariwala d39e3ee638 feat: offline support for link surveys (#7694)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-10 11:27:48 +00:00
dingdyan d85242a86b fix: handle internal server error toast behavior in create organization (#7662)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-10 11:13:10 +00:00
Bhagya Amarasinghe ef53065abc feat: support GKE Envoy ingress split with numeric ports and service annotations (#7704) 2026-04-10 09:22:19 +00:00
Dhruwang Jariwala 805c1c6874 fix: (duplicate) server error toast handling (#7701) 2026-04-10 09:22:16 +00:00
Niels Kaspers 01687e8907 fix: add TERMS_URL support to survey link footers (#7670) 2026-04-10 09:21:11 +00:00
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
1278 changed files with 74206 additions and 26988 deletions
@@ -1,58 +0,0 @@
---
name: integration-nextjs-pages-router
description: PostHog integration for Next.js Pages Router applications
metadata:
author: PostHog
version: 1.8.1
---
# PostHog integration for Next.js Pages Router
This skill helps you add PostHog analytics to Next.js Pages Router applications.
## Workflow
Follow these steps in order to complete the integration:
1. `basic-integration-1.0-begin.md` - PostHog Setup - Begin ← **Start here**
2. `basic-integration-1.1-edit.md` - PostHog Setup - Edit
3. `basic-integration-1.2-revise.md` - PostHog Setup - Revise
4. `basic-integration-1.3-conclude.md` - PostHog Setup - Conclusion
## Reference files
- `EXAMPLE.md` - Next.js Pages Router example project code
- `next-js.md` - Next.js - docs
- `identify-users.md` - Identify users - docs
- `basic-integration-1.0-begin.md` - PostHog setup - begin
- `basic-integration-1.1-edit.md` - PostHog setup - edit
- `basic-integration-1.2-revise.md` - PostHog setup - revise
- `basic-integration-1.3-conclude.md` - PostHog setup - conclusion
The example project shows the target implementation pattern. Consult the documentation for API details.
## Key principles
- **Environment variables**: Always use environment variables for PostHog keys. Never hardcode them.
- **Minimal changes**: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
- **Match the example**: Your implementation should follow the example project's patterns as closely as possible.
## Framework guidelines
- For Next.js 15.3+, initialize PostHog in instrumentation-client.ts for the simplest setup
- For feature flags, use useFeatureFlagEnabled() or useFeatureFlagPayload() hooks - they handle loading states and external sync automatically
- Add analytics capture in event handlers where user actions occur, NOT in useEffect reacting to state changes
- Do NOT use useEffect for data transformation - calculate derived values during render instead
- Do NOT use useEffect to respond to user events - put that logic in the event handler itself
- Do NOT use useEffect to chain state updates - calculate all related updates together in the event handler
- Do NOT use useEffect to notify parent components - call the parent callback alongside setState in the event handler
- To reset component state when a prop changes, pass the prop as the component's key instead of using useEffect
- useEffect is ONLY for synchronizing with external systems (non-React widgets, browser APIs, network subscriptions)
## Identifying users
Identify users during login and signup events. Refer to the example code and documentation for the correct identify pattern for this framework. If both frontend and backend code exist, pass the client-side session and distinct ID using `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers to maintain correlation.
## Error tracking
Add PostHog error tracking to relevant files, particularly around critical user flows and API boundaries.
@@ -1,761 +0,0 @@
# PostHog Next.js Pages Router Example Project
Repository: https://github.com/PostHog/context-mill
Path: basics/next-pages-router
---
## README.md
# PostHog Next.js pages router example
This is a [Next.js](https://nextjs.org) Pages Router example demonstrating PostHog integration with product analytics, session replay, feature flags, and error tracking.
## Features
- **Product Analytics**: Track user events and behaviors
- **Session Replay**: Record and replay user sessions
- **Error Tracking**: Capture and track errors
- **User Authentication**: Demo login system with PostHog user identification
- **Server-side & Client-side Tracking**: Examples of both tracking methods
- **Reverse Proxy**: PostHog ingestion through Next.js rewrites
## Getting Started
### 1. Install Dependencies
```bash
npm install
# or
pnpm install
```
### 2. Configure Environment Variables
Create a `.env.local` file in the root directory:
```bash
NEXT_PUBLIC_POSTHOG_KEY=your_posthog_project_api_key
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
Get your PostHog API key from your [PostHog project settings](https://app.posthog.com/project/settings).
### 3. Run the Development Server
```bash
npm run dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the app.
## Project Structure
```
src/
├── components/
│ └── Header.tsx # Navigation header with auth state
├── contexts/
│ └── AuthContext.tsx # Authentication context with PostHog integration
├── lib/
│ └── posthog-server.ts # Server-side PostHog client
├── pages/
│ ├── _app.tsx # App wrapper with Auth provider
│ ├── _document.tsx # Document wrapper
│ ├── index.tsx # Home/Login page
│ ├── burrito.tsx # Demo feature page with event tracking
│ ├── profile.tsx # User profile with error tracking demo
│ └── api/
│ └── auth/
│ └── login.ts # Login API with server-side tracking
└── styles/
└── globals.css # Global styles
instrumentation-client.ts # Client-side PostHog initialization
```
## Key Integration Points
### Client-side initialization (instrumentation-client.ts)
```typescript
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
defaults: '2026-01-30',
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});
```
### User identification (AuthContext.tsx)
```typescript
posthog.identify(username, {
username: username,
});
```
### Event tracking (burrito.tsx)
```typescript
posthog.capture('burrito_considered', {
total_considerations: count,
username: username,
});
```
### Error tracking (profile.tsx)
```typescript
posthog.captureException(error);
```
### Server-side tracking (api/auth/login.ts)
```typescript
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: { ... }
});
```
## Pages router differences from app router
This example uses Next.js Pages Router instead of App Router. Key differences:
1. **File-based routing**: Pages in `src/pages/` instead of `src/app/`
2. **_app.tsx**: Custom App component wraps all pages
3. **API Routes**: Located in `src/pages/api/`
4. **No 'use client'**: All pages are client-side by default
5. **useRouter**: From `next/router` instead of `next/navigation`
6. **Head component**: Using `next/head` for metadata instead of `metadata` export
## Learn More
- [PostHog Documentation](https://posthog.com/docs)
- [Next.js Pages Router Documentation](https://nextjs.org/docs/pages)
- [PostHog Next.js Integration Guide](https://posthog.com/docs/libraries/next-js)
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new).
Check out the [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details.
---
## instrumentation-client.ts
```ts
import posthog from "posthog-js"
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://us.posthog.com",
// Include the defaults option as required by PostHog
defaults: '2026-01-30',
// Enables capturing unhandled exceptions via Error Tracking
capture_exceptions: true,
// Turn on debug in development mode
debug: process.env.NODE_ENV === "development",
});
//IMPORTANT: Never combine this approach with other client-side PostHog initialization approaches, especially components like a PostHogProvider. instrumentation-client.ts is the correct solution for initializating client-side PostHog in Next.js 15.3+ apps.
```
---
## next.config.ts
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: true,
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
},
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
};
export default nextConfig;
```
---
## src/components/Header.tsx
```tsx
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
export default function Header() {
const { user, logout } = useAuth();
return (
<header className="header">
<div className="header-container">
<nav>
<Link href="/">Home</Link>
{user && (
<>
<Link href="/burrito">Burrito Consideration</Link>
<Link href="/profile">Profile</Link>
</>
)}
</nav>
<div className="user-section">
{user ? (
<>
<span>Welcome, {user.username}!</span>
<button onClick={logout} className="btn-logout">
Logout
</button>
</>
) : (
<span>Not logged in</span>
)}
</div>
</div>
</header>
);
}
```
---
## src/contexts/AuthContext.tsx
```tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import posthog from 'posthog-js';
interface User {
username: string;
burritoConsiderations: number;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
incrementBurritoConsiderations: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const users: Map<string, User> = new Map();
export function AuthProvider({ children }: { children: ReactNode }) {
// Use lazy initializer to read from localStorage only once on mount
const [user, setUser] = useState<User | null>(() => {
if (typeof window === 'undefined') return null;
const storedUsername = localStorage.getItem('currentUser');
if (storedUsername) {
const existingUser = users.get(storedUsername);
if (existingUser) {
return existingUser;
}
}
return null;
});
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user: userData } = await response.json();
// Get or create user in local map
let localUser = users.get(username);
if (!localUser) {
localUser = userData as User;
users.set(username, localUser);
}
setUser(localUser);
localStorage.setItem('currentUser', username);
// Identify user in PostHog using username as distinct ID
posthog.identify(username, {
username: username,
});
// Capture login event
posthog.capture('user_logged_in', {
username: username,
});
return true;
}
return false;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const logout = () => {
// Capture logout event before resetting
posthog.capture('user_logged_out');
posthog.reset();
setUser(null);
localStorage.removeItem('currentUser');
};
const incrementBurritoConsiderations = () => {
if (user) {
user.burritoConsiderations++;
users.set(user.username, user);
setUser({ ...user });
}
};
return (
<AuthContext.Provider value={{ user, login, logout, incrementBurritoConsiderations }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
```
---
## src/lib/posthog-server.ts
```ts
import { PostHog } from 'posthog-node';
let posthogClient: PostHog | null = null;
export function getPostHogClient() {
if (!posthogClient) {
posthogClient = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_KEY!,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
}
);
}
return posthogClient;
}
export async function shutdownPostHog() {
if (posthogClient) {
await posthogClient.shutdown();
}
}
```
---
## src/pages/_app.tsx
```tsx
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { AuthProvider } from "@/contexts/AuthContext";
export default function App({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
```
---
## src/pages/_document.tsx
```tsx
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
```
---
## src/pages/api/auth/login.ts
```ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPostHogClient } from '@/lib/posthog-server';
const users = new Map<string, { username: string; burritoConsiderations: number }>();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
let user = users.get(username);
const isNewUser = !user;
if (!user) {
user = { username, burritoConsiderations: 0 };
users.set(username, user);
}
// Capture server-side login event
const posthog = getPostHogClient();
posthog.capture({
distinctId: username,
event: 'server_login',
properties: {
username: username,
isNewUser: isNewUser,
source: 'api'
}
});
// Identify user on server side
posthog.identify({
distinctId: username,
properties: {
username: username,
createdAt: isNewUser ? new Date().toISOString() : undefined
}
});
return res.status(200).json({ success: true, user });
}
```
---
## src/pages/api/hello.ts
```ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
type Data = {
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>,
) {
res.status(200).json({ name: "John Doe" });
}
```
---
## src/pages/burrito.tsx
```tsx
import { useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import posthog from 'posthog-js';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';
export default function BurritoPage() {
const { user, incrementBurritoConsiderations } = useAuth();
const router = useRouter();
const [hasConsidered, setHasConsidered] = useState(false);
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const handleConsideration = () => {
incrementBurritoConsiderations();
setHasConsidered(true);
setTimeout(() => setHasConsidered(false), 2000);
// Capture burrito consideration event
posthog.capture('burrito_considered', {
total_considerations: user.burritoConsiderations + 1,
username: user.username,
});
};
return (
<>
<Head>
<title>Burrito Consideration - Burrito Consideration App</title>
<meta name="description" content="Consider the potential of burritos" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header />
<main>
<div className="container">
<h1>Burrito consideration zone</h1>
<p>Take a moment to truly consider the potential of burritos.</p>
<div style={{ textAlign: 'center' }}>
<button
onClick={handleConsideration}
className="btn-burrito"
>
I have considered the burrito potential
</button>
{hasConsidered && (
<p className="success">
Thank you for your consideration! Count: {user.burritoConsiderations}
</p>
)}
</div>
<div className="stats">
<h3>Consideration stats</h3>
<p>Total considerations: {user.burritoConsiderations}</p>
</div>
</div>
</main>
</>
);
}
```
---
## src/pages/index.tsx
```tsx
import { useState } from 'react';
import Head from 'next/head';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';
export default function Home() {
const { user, login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const success = await login(username, password);
if (success) {
setUsername('');
setPassword('');
} else {
setError('Please provide both username and password');
}
} catch (err) {
console.error('Login failed:', err);
setError('An error occurred during login');
}
};
return (
<>
<Head>
<title>Burrito Consideration App</title>
<meta name="description" content="Consider the potential of burritos" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header />
<main>
{user ? (
<div className="container">
<h1>Welcome back, {user.username}!</h1>
<p>You are now logged in. Feel free to explore:</p>
<ul>
<li>Consider the potential of burritos</li>
<li>View your profile and statistics</li>
</ul>
</div>
) : (
<div className="container">
<h1>Welcome to Burrito Consideration App</h1>
<p>Please sign in to begin your burrito journey</p>
<form onSubmit={handleSubmit} className="form">
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter any username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter any password"
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary">Sign In</button>
</form>
<p className="note">
Note: This is a demo app. Use any username and password to sign in.
</p>
</div>
)}
</main>
</>
);
}
```
---
## src/pages/profile.tsx
```tsx
import Head from 'next/head';
import { useRouter } from 'next/router';
import posthog from 'posthog-js';
import { useAuth } from '@/contexts/AuthContext';
import Header from '@/components/Header';
export default function ProfilePage() {
const { user } = useAuth();
const router = useRouter();
// Redirect to home if not logged in
if (!user) {
router.push('/');
return null;
}
const triggerTestError = () => {
try {
throw new Error('Test error for PostHog error tracking');
} catch (err) {
posthog.captureException(err);
console.error('Captured error:', err);
alert('Error captured and sent to PostHog!');
}
};
return (
<>
<Head>
<title>Profile - Burrito Consideration App</title>
<meta name="description" content="Your burrito consideration profile" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header />
<main>
<div className="container">
<h1>User Profile</h1>
<div className="stats">
<h2>Your Information</h2>
<p><strong>Username:</strong> {user.username}</p>
<p><strong>Burrito Considerations:</strong> {user.burritoConsiderations}</p>
</div>
<div style={{ marginTop: '2rem' }}>
<button onClick={triggerTestError} className="btn-primary" style={{ backgroundColor: '#dc3545' }}>
Trigger Test Error (for PostHog)
</button>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>Your Burrito Journey</h3>
{user.burritoConsiderations === 0 ? (
<p>You haven&apos;t considered any burritos yet. Visit the Burrito Consideration page to start!</p>
) : user.burritoConsiderations === 1 ? (
<p>You&apos;ve considered the burrito potential once. Keep going!</p>
) : user.burritoConsiderations < 5 ? (
<p>You&apos;re getting the hang of burrito consideration!</p>
) : user.burritoConsiderations < 10 ? (
<p>You&apos;re becoming a burrito consideration expert!</p>
) : (
<p>You are a true burrito consideration master! 🌯</p>
)}
</div>
</div>
</main>
</>
);
}
```
---
@@ -1,43 +0,0 @@
---
title: PostHog Setup - Begin
description: Start the event tracking setup process by analyzing the project and creating an event tracking plan
---
We're making an event tracking plan for this project.
Before proceeding, find any existing `posthog.capture()` code. Make note of event name formatting.
From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events. Also look for additional files related to login that could be used for identifying users, along with error handling. Read the files. If a file is already well-covered by PostHog events, replace it with another option. Do not spawn subagents.
Look for opportunities to track client-side events.
**IMPORTANT: Server-side events are REQUIRED** if the project includes any instrumentable server-side code. If the project has API routes (e.g., `app/api/**/route.ts`) or Server Actions, you MUST include server-side events for critical business operations like:
- Payment/checkout completion
- Webhook handlers
- Authentication endpoints
Do not skip server-side events - they capture actions that cannot be tracked client-side.
Create a new file with a JSON array at the root of the project: .posthog-events.json. It should include one object for each event we want to add: event name, event description, and the file path we want to place the event in. If events already exist, don't duplicate them; supplement them.
Track actions only, not pageviews. These can be captured automatically. Exceptions can be made for "viewed"-type events that correspond to the top of a conversion funnel.
As you review files, make an internal note of opportunities to identify users and catch errors. We'll need them for the next step.
## Status
Before beginning a phase of the setup, you will send a status message with the exact prefix '[STATUS]', as in:
[STATUS] Checking project structure.
Status to report in this phase:
- Checking project structure
- Verifying PostHog dependencies
- Generating events based on project
---
**Upon completion, continue with:** [basic-integration-1.1-edit.md](basic-integration-1.1-edit.md)
@@ -1,37 +0,0 @@
---
title: PostHog Setup - Edit
description: Implement PostHog event tracking in the identified files, following best practices and the example project
---
For each of the files and events noted in .posthog-events.json, make edits to capture events using PostHog. Make sure to set up any helper files needed. Carefully examine the included example project code: your implementation should match it as closely as possible. Do not spawn subagents.
Use environment variables for PostHog keys. Do not hardcode PostHog keys.
If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
For each event, add useful properties, and use your access to the PostHog source code to ensure correctness. You also have access to documentation about creating new events with PostHog. Consider this documentation carefully and follow it closely before adding events. Your integration should be based on documented best practices. Carefully consider how the user project's framework version may impact the correct PostHog integration approach.
Remember that you can find the source code for any dependency in the node_modules directory. This may be necessary to properly populate property names. There are also example project code files available via the PostHog MCP; use these for reference.
Where possible, add calls for PostHog's identify() function on the client side upon events like logins and signups. Use the contents of login and signup forms to identify users on submit. If there is server-side code, pass the client-side session and distinct ID to the server-side code to identify the user. On the server side, make sure events have a matching distinct ID where relevant.
It's essential to do this in both client code and server code, so that user behavior from both domains is easy to correlate.
You should also add PostHog exception capture error tracking to these files where relevant.
Remember: Do not alter the fundamental architecture of existing files. Make your additions minimal and targeted.
Remember the documentation and example project resources you were provided at the beginning. Read them now.
## Status
Status to report in this phase:
- Inserting PostHog capture code
- A status message for each file whose edits you are planning, including a high level summary of changes
- A status message for each file you have edited
---
**Upon completion, continue with:** [basic-integration-1.2-revise.md](basic-integration-1.2-revise.md)
@@ -1,22 +0,0 @@
---
title: PostHog Setup - Revise
description: Review and fix any errors in the PostHog integration implementation
---
Check the project for errors. Read the package.json file for any type checking or build scripts that may provide input about what to fix. Remember that you can find the source code for any dependency in the node_modules directory. Do not spawn subagents.
Ensure that any components created were actually used.
Once all other tasks are complete, run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Do not run formatting or linting across the entire project's codebase.
## Status
Status to report in this phase:
- Finding and correcting errors
- Report details of any errors you fix
- Linting, building and prettying
---
**Upon completion, continue with:** [basic-integration-1.3-conclude.md](basic-integration-1.3-conclude.md)
@@ -1,38 +0,0 @@
---
title: PostHog Setup - Conclusion
description: Review and fix any errors in the PostHog integration implementation
---
Use the PostHog MCP to create a new dashboard named "Analytics basics" based on the events created here. Make sure to use the exact same event names as implemented in the code. Populate it with up to five insights, with special emphasis on things like conversion funnels, churn events, and other business critical insights.
Search for a file called `.posthog-events.json` and read it for available events. Do not spawn subagents.
Create the file posthog-setup-report.md. It should include a summary of the integration edits, a table with the event names, event descriptions, and files where events were added, along with a list of links for the dashboard and insights created. Follow this format:
<wizard-report>
# PostHog post-wizard report
The wizard has completed a deep integration of your project. [Detailed summary of changes]
[table of events/descriptions/files]
## Next steps
We've built some insights and a dashboard for you to keep an eye on user behavior, based on the events we just instrumented:
[links]
### Agent skill
We've left an agent skill folder in your project. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog.
</wizard-report>
Upon completion, remove .posthog-events.json.
## Status
Status to report in this phase:
- Configured dashboard: [insert PostHog dashboard URL]
- Created setup report: [insert full local file path]
@@ -1,202 +0,0 @@
# Identify users - Docs
Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.
This is straightforward to do when [capturing backend events](/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a `distinct_id`, which is a required argument.
However, in the frontend of a [web](/docs/libraries/js/features#capturing-events.md) or [mobile app](/docs/libraries/ios#capturing-events.md), a `distinct_id` is not a required argument — PostHog's SDKs will generate an anonymous `distinct_id` for you automatically and you can capture events anonymously, provided you use the appropriate [configuration](/docs/libraries/js/features#capturing-anonymous-events.md).
To link events to specific users, call `identify`:
PostHog AI
### Web
```javascript
posthog.identify(
'distinct_id', // Replace 'distinct_id' with your user's unique identifier
{ email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
);
```
### Android
```kotlin
PostHog.identify(
distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
// optional: set additional person properties
userProperties = mapOf(
"name" to "Max Hedgehog",
"email" to "max@hedgehogmail.com"
)
)
```
### iOS
```swift
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties
```
### React Native
```jsx
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
email: 'max@hedgehogmail.com', // optional: set additional person properties
name: 'Max Hedgehog'
})
```
### Dart
```dart
await Posthog().identify(
userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
userProperties: {
email: "max@hedgehogmail.com", // optional: set additional person properties
name: "Max Hedgehog"
});
```
Events captured after calling `identify` are identified events and this creates a person profile if one doesn't exist already.
Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.
## How identify works
When a user starts browsing your website or app, PostHog automatically assigns them an **anonymous ID**, which is stored locally.
Provided you've [configured persistence](/docs/libraries/js/persistence.md) to use cookies or `localStorage`, this enables us to track anonymous users even across different sessions.
By calling `identify` with a `distinct_id` of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.
Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.
This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.
Using identify in the backend
Although you can call `identify` using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling `identify` only updates person profiles.
## Best practices when using `identify`
### 1\. Call `identify` as soon as you're able to
In your frontend, you should call `identify` as soon as you're able to.
Typically, this is every time your **app loads** for the first time, and directly after your **users log in**.
This ensures that events sent during your users' sessions are correctly associated with them.
You only need to call `identify` once per session, and you should avoid calling it multiple times unnecessarily.
If you call `identify` multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.
### 2\. Use unique strings for distinct IDs
If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:
- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like `null`, `true`, or `distinctId`.
PostHog also has built-in protections to stop the most common distinct ID mistakes.
### 3\. Reset after logout
If a user logs out on your frontend, you should call `reset()` to unlink any future events made on that device with that user.
This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.
**We strongly recommend you call `reset` on logout even if you don't expect users to share a computer.**
You can do that like so:
PostHog AI
### Web
```javascript
posthog.reset()
```
### iOS
```swift
PostHogSDK.shared.reset()
```
### Android
```kotlin
PostHog.reset()
```
### React Native
```jsx
posthog.reset()
```
### Dart
```dart
Posthog().reset()
```
If you *also* want to reset the `device_id` so that the device will be considered a new device in future events, you can pass `true` as an argument:
Web
PostHog AI
```javascript
posthog.reset(true)
```
### 4\. Person profiles and properties
You'll notice that one of the parameters in the `identify` method is a `properties` object.
This enables you to set [person properties](/docs/product-analytics/person-properties.md).
Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.
Person properties can also be set being adding a `$set` property to a event `capture` call.
See our [person properties docs](/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.
### 5\. Use deep links between platforms
We recommend you call `identify` [as soon as you're able](#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.
This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:
- Onboarding and signup flows before authentication.
- Unauthenticated web pages redirecting to authenticated mobile apps.
- Authenticated web apps prompting an app download.
In these cases, you can use a [deep link](https://developer.android.com/training/app-links/deep-linking) on Android and [universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) on iOS to identify users.
1. Use `posthog.get_distinct_id()` to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog.
2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
3. When the user is redirected to the app, parse the deep link and handle the following cases:
- The user is already authenticated on the mobile app. In this case, call [`posthog.alias()`](/docs/libraries/js/features#alias.md) with the distinct ID from the web. This associates the two distinct IDs as a single person.
- The user is unauthenticated. In this case, call [`posthog.identify()`](/docs/libraries/js/features#identifying-users.md) with the distinct ID from the web. Events will be associated with this distinct ID.
As long as you associate the distinct IDs with `posthog.identify()` or `posthog.alias()`, you can track events generated across platforms.
## Further reading
- [Identifying users docs](/docs/product-analytics/identify.md)
- [How person processing works](/docs/how-posthog-works/ingestion-pipeline#2-person-processing.md)
- [An introductory guide to identifying users in PostHog](/tutorials/identifying-users-guide.md)
### Community questions
Ask a question
### Was this page useful?
HelpfulCould be better
@@ -1,377 +0,0 @@
# Next.js - Docs
PostHog makes it easy to get data about traffic and usage of your [Next.js](https://nextjs.org/) app. Integrating PostHog into your site enables analytics about user behavior, custom events capture, session recordings, feature flags, and more.
This guide walks you through integrating PostHog into your Next.js app using the [React](/docs/libraries/react.md) and the [Node.js](/docs/libraries/node.md) SDKs.
> You can see a working example of this integration in our [Next.js demo app](https://github.com/PostHog/posthog-js/tree/main/playground/nextjs).
Next.js has both client and server-side rendering, as well as pages and app routers. We'll cover all of these options in this guide.
## Prerequisites
To follow this guide along, you need:
1. A PostHog instance (either [Cloud](https://app.posthog.com/signup) or [self-hosted](/docs/self-host.md))
2. A Next.js application
## Beta: integration via LLM
Install PostHog for Next.js in seconds with our wizard by running this prompt with [LLM coding agents](/blog/envoy-wizard-llm-agent.md) like Cursor and Bolt, or by running it in your terminal.
`npx @posthog/wizard@latest`
[Learn more](/wizard.md)
Or, to integrate manually, continue with the rest of this guide.
## Client-side setup
Install `posthog-js` using your package manager:
PostHog AI
### npm
```bash
npm install --save posthog-js
```
### Yarn
```bash
yarn add posthog-js
```
### pnpm
```bash
pnpm add posthog-js
```
### Bun
```bash
bun add posthog-js
```
Add your environment variables to your `.env.local` file and to your hosting provider (e.g. Vercel, Netlify, AWS). You can find your project token in your [project settings](https://app.posthog.com/project/settings).
.env.local
PostHog AI
```shell
NEXT_PUBLIC_POSTHOG_TOKEN=<ph_project_token>
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
```
These values need to start with `NEXT_PUBLIC_` to be accessible on the client-side.
## Integration
Next.js provides the [`instrumentation-client.ts|js`](https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client) file for client-side setup. Add it to the root of your Next.js app (for both app and pages router) and initialize PostHog in it like this:
PostHog AI
### instrumentation-client.js
```javascript
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30'
});
```
### instrumentation-client.ts
```typescript
import posthog from 'posthog-js'
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2026-01-30'
});
```
Bootstrapping with `instrumentation-client`
When using `instrumentation-client`, the values you pass to `posthog.init` remain fixed for the entire session. This means bootstrapping only works if you evaluate flags **before your app renders** (for example, on the server).
If you need flag values after the app has rendered, youll want to:
- Evaluate the flag on the server and pass the value into your app, or
- Evaluate the flag in an earlier page/state, then store and re-use it when needed.
Both approaches avoid flicker and give you the same outcome as bootstrapping, as long as you use the same `distinct_id` across client and server.
See the [bootstrapping guide](/docs/feature-flags/bootstrapping.md) for more information.
Set up a reverse proxy (recommended)
We recommend [setting up a reverse proxy](/docs/advanced/proxy.md), so that events are less likely to be intercepted by tracking blockers.
We have our [own managed reverse proxy service](/docs/advanced/proxy/managed-reverse-proxy.md), which is free for all PostHog Cloud users, routes through our infrastructure, and makes setting up your proxy easy.
If you don't want to use our managed service then there are several other options for creating a reverse proxy, including using [Cloudflare](/docs/advanced/proxy/cloudflare.md), [AWS Cloudfront](/docs/advanced/proxy/cloudfront.md), and [Vercel](/docs/advanced/proxy/vercel.md).
Grouping products in one project (recommended)
If you have multiple customer-facing products (e.g. a marketing website + mobile app + web app), it's best to install PostHog on them all and [group them in one project](/docs/settings/projects.md).
This makes it possible to track users across their entire journey (e.g. from visiting your marketing website to signing up for your product), or how they use your product across multiple platforms.
Add IPs to Firewall/WAF allowlists (recommended)
For certain features like [heatmaps](/docs/toolbar/heatmaps.md), your Web Application Firewall (WAF) may be blocking PostHogs requests to your site. Add these IP addresses to your WAF allowlist or rules to let PostHog access your site.
**EU**: `3.75.65.221`, `18.197.246.42`, `3.120.223.253`
**US**: `44.205.89.55`, `52.4.194.122`, `44.208.188.173`
These are public, stable IPs used by PostHog services (e.g., Celery tasks for snapshots).
## Accessing PostHog
Once initialized in `instrumentation-client.js|ts`, import `posthog` from `posthog-js` anywhere and call the methods you need on the `posthog` object.
JavaScript
PostHog AI
```javascript
'use client'
import posthog from 'posthog-js'
export default function Home() {
return (
<div>
<button onClick={() => posthog.capture('test_event')}>
Click me for an event
</button>
</div>
);
}
```
### Using React hooks
The [React feature flag hooks](/docs/libraries/react#feature-flags.md) work automatically when PostHog is initialized via `instrumentation-client.ts`. The hooks use the initialized posthog-js singleton:
JavaScript
PostHog AI
```javascript
'use client'
import { useFeatureFlagEnabled } from 'posthog-js/react'
export default function FeatureComponent() {
const showNewFeature = useFeatureFlagEnabled('new-feature')
return showNewFeature ? <NewFeature /> : <OldFeature />
}
```
### Usage
See the [React SDK docs](/docs/libraries/react.md) for examples of how to use:
- [`posthog-js` functions like custom event capture, user identification, and more.](/docs/libraries/react#using-posthog-js-functions.md)
- [Feature flags including variants and payloads.](/docs/libraries/react#feature-flags.md)
You can also read [the full `posthog-js` documentation](/docs/libraries/js/features.md) for all the usable functions.
## Server-side analytics
Next.js enables you to both server-side render pages and add server-side functionality. To integrate PostHog into your Next.js app on the server-side, you can use the [Node SDK](/docs/libraries/node.md).
First, install the `posthog-node` library:
PostHog AI
### npm
```bash
npm install posthog-node --save
```
### Yarn
```bash
yarn add posthog-node
```
### pnpm
```bash
pnpm add posthog-node
```
### Bun
```bash
bun add posthog-node
```
### Router-specific instructions
## App router
For the app router, we can initialize the `posthog-node` SDK once with a `PostHogClient` function, and import it into files.
This enables us to send events and fetch data from PostHog on the server without making client-side requests.
JavaScript
PostHog AI
```javascript
// app/posthog.js
import { PostHog } from 'posthog-node'
export default function PostHogClient() {
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0
})
return posthogClient
}
```
> **Note:** Because server-side functions in Next.js can be short-lived, we set `flushAt` to `1` and `flushInterval` to `0`.
>
> - `flushAt` sets how many capture calls we should flush the queue (in one batch).
> - `flushInterval` sets how many milliseconds we should wait before flushing the queue. Setting them to the lowest number ensures events are sent immediately and not batched. We also need to call `await posthog.shutdown()` once done.
To use this client, we import it into our pages and call it with the `PostHogClient` function:
JavaScript
PostHog AI
```javascript
import Link from 'next/link'
import PostHogClient from '../posthog'
export default async function About() {
const posthog = PostHogClient()
const flags = await posthog.getAllFlags(
'user_distinct_id' // replace with a user's distinct ID
);
await posthog.shutdown()
return (
<main>
<h1>About</h1>
<Link href="/">Go home</Link>
{ flags['main-cta'] &&
<Link href="http://posthog.com/">Go to PostHog</Link>
}
</main>
)
}
```
## Pages router
For the pages router, we can use the `getServerSideProps` function to access PostHog on the server-side, send events, evaluate feature flags, and more.
This looks like this:
JavaScript
PostHog AI
```javascript
// pages/posts/[id].js
import { useContext, useEffect, useState } from 'react'
import { getServerSession } from "next-auth/next"
import { PostHog } from 'posthog-node'
export default function Post({ post, flags }) {
const [ctaState, setCtaState] = useState()
useEffect(() => {
if (flags) {
setCtaState(flags['blog-cta'])
}
})
return (
<div>
<h1>{post.title}</h1>
<p>By: {post.author}</p>
<p>{post.content}</p>
{ctaState &&
<p><a href="/">Go to PostHog</a></p>
}
<button onClick={likePost}>Like</button>
</div>
)
}
export async function getServerSideProps(ctx) {
const session = await getServerSession(ctx.req, ctx.res)
let flags = null
if (session) {
const client = new PostHog(
process.env.NEXT_PUBLIC_POSTHOG_TOKEN,
{
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
}
)
flags = await client.getAllFlags(session.user.email);
client.capture({
distinctId: session.user.email,
event: 'loaded blog article',
properties: {
$current_url: ctx.req.url,
},
});
await client.shutdown()
}
const { posts } = await import('../../blog.json')
const post = posts.find((post) => post.id.toString() === ctx.params.id)
return {
props: {
post,
flags
},
}
}
```
> **Note**: Make sure to *always* call `await client.shutdown()` after sending events from the server-side. PostHog queues events into larger batches, and this call forces all batched events to be flushed immediately.
### Server-side configuration
Next.js overrides the default `fetch` behavior on the server to introduce their own cache. PostHog ignores that cache by default, as this is Next.js's default behavior for any fetch call.
You can override that configuration when initializing PostHog, but make sure you understand the pros/cons of using Next.js's cache and that you might get cached results rather than the actual result our server would return. This is important for feature flags, for example.
TSX
PostHog AI
```jsx
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, {
// ... your configuration
fetch_options: {
cache: 'force-cache', // Use Next.js cache
next_options: { // Passed to the `next` option for `fetch`
revalidate: 60, // Cache for 60 seconds
tags: ['posthog'], // Can be used with Next.js `revalidateTag` function
},
}
})
```
## Configuring a reverse proxy to PostHog
To improve the reliability of client-side tracking and make requests less likely to be intercepted by tracking blockers, you can setup a reverse proxy in Next.js. Read more about deploying a reverse proxy using [Next.js rewrites](/docs/advanced/proxy/nextjs.md), [Next.js middleware](/docs/advanced/proxy/nextjs-middleware.md), and [Vercel rewrites](/docs/advanced/proxy/vercel.md).
## Further reading
- [How to set up Next.js analytics, feature flags, and more](/tutorials/nextjs-analytics.md)
- [How to set up Next.js pages router analytics, feature flags, and more](/tutorials/nextjs-pages-analytics.md)
- [How to set up Next.js A/B tests](/tutorials/nextjs-ab-tests.md)
### Community questions
Ask a question
### Was this page useful?
HelpfulCould be better
+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
'''
+49 -1
View File
@@ -94,12 +94,26 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion SSO confirmation #
###########################################
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
##########
# Other #
@@ -132,6 +146,31 @@ AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET=
AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, 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
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
@@ -150,6 +189,7 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -184,6 +224,14 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
# TELEMETRY_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -230,4 +278,4 @@ REDIS_URL=redis://localhost:6379
# 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 }}
redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
- 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') }}
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@v3
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1
View File
@@ -92,3 +92,4 @@ jobs:
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+37 -48
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
@@ -85,65 +85,48 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
- name: Start RustFS Server
run: |
set -euo pipefail
# Start MinIO server in background
# Start RustFS server in background
docker run -d \
--name minio-server \
--name rustfs-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
echo "MinIO server started"
echo "RustFS server started"
- name: Wait for MinIO and create S3 bucket
- name: Bootstrap RustFS bucket and browser upload CORS
run: |
set -euo pipefail
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
- name: Build App
run: |
@@ -242,8 +225,14 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
+28
View File
@@ -155,3 +155,31 @@ jobs:
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
+30
View File
@@ -0,0 +1,30 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+3 -2
View File
@@ -2,6 +2,7 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -39,7 +40,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -49,7 +50,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+1 -2
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv
# Playwright
/test-results/
**/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
@@ -66,4 +66,3 @@ i18n.cache
stats.html
# next-agents-md
.next-docs/
.env
+13 -1
View File
@@ -1 +1,13 @@
pnpm lint-staged
#!/usr/bin/env sh
if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
elif command -v npm >/dev/null 2>&1; then
npm exec --yes pnpm@10.32.1 lint-staged
elif command -v corepack >/dev/null 2>&1; then
corepack pnpm lint-staged
else
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "Install Node.js tooling or update your PATH, then retry the commit."
exit 127
fi
+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`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.
+1 -25
View File
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community-managed One Click Hosting
##### Railway
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development
### Prerequisites
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<p align="right"><a href="#top">🔼 Back to top</a></p>
<a id="readme-de"></a>
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.15",
"@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.15",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.56.1",
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-links": "10.3.6",
"@storybook/addon-onboarding": "10.3.6",
"@storybook/react-vite": "10.3.6",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.14",
"storybook": "10.2.15",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.15"
"eslint-plugin-storybook": "10.3.6",
"storybook": "10.3.6",
"vite": "7.3.3"
}
}
+11 -6
View File
@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate
@@ -67,6 +67,7 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \
--mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
#
@@ -74,9 +75,10 @@ RUN --mount=type=secret,id=database_url \
#
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
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 \
&& 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
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid
# Runtime migrations import uuid v7 from the database package, so copy the
# 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
RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -164,4 +169,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]
CMD ["/home/nextjs/start.sh"]
@@ -1,5 +1,6 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const channel = project.config.channel || null;
@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
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 { children } = props;
@@ -2,6 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils";
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
elements: [
{
id: "q1",
type: "openText" as const,
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
charLimit: { enabled: true, max: 1000 },
},
],
},
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
});
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);
expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-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);
expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => {
throw new Error("Test error");
}) as TFunction;
}) as unknown as TFunction;
const result = getXMTemplates(tMock);
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
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 project = await getProjectByEnvironmentId(environment.id);
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);
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => {
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -22,14 +22,12 @@ export const getTeamsByOrganizationId = reactCache(
},
});
const projectTeams = teams.map((team) => ({
return teams.map((team: TOrganizationTeam) => ({
id: team.id,
name: team.name,
}));
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error instanceof PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -5,7 +5,10 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
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 { children } = props;
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
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 t = await getTranslate();
@@ -26,7 +26,8 @@ const Page = async (props) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
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 (
<div className="flex min-h-full min-w-full flex-row">
@@ -45,6 +46,8 @@ const Page = async (props) => {
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]}
/>
</div>
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props) => {
const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -22,7 +25,7 @@ const ProjectOnboardingLayout = async (props) => {
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -33,7 +36,7 @@ const ProjectOnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
return (
@@ -2,6 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -41,6 +42,16 @@ const Page = async (props: ChannelPageProps) => {
const projects = await getUserProjects(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
@@ -8,7 +9,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
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 { children } = props;
@@ -25,11 +29,13 @@ const OnboardingLayout = async (props) => {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`);
@@ -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;
@@ -18,6 +18,7 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -242,7 +243,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -1,11 +1,13 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -42,14 +44,26 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
throw new ResourceNotFoundError(t("common.team"), null);
}
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
}
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option) => {
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
const Icon = option.icon;
return (
<OptionCard
@@ -1,8 +1,12 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props) => {
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -14,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
return (
@@ -6,15 +6,26 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
interface ConfirmationPageProps {
environmentId: string;
}
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
export const ConfirmationPage = () => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
useEffect(() => {
setShowConfetti(true);
if (globalThis.window === undefined) {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
}
}, []);
return (
@@ -30,7 +41,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
</p>
</div>
<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")}
</Link>
</Button>
@@ -3,13 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic";
const Page = async (props) => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
const Page = async () => {
return (
<PageContentWrapper>
<ConfirmationPage environmentId={environmentId?.toString()} />
<ConfirmationPage />
</PageContentWrapper>
);
};
@@ -2,15 +2,19 @@
import { z } from "zod";
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 { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
@@ -26,66 +30,75 @@ const ZCreateProjectAction = z.object({
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
};
],
});
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
const organization = await getOrganization(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
)
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", project.id, { name: project.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: project.id,
name: project.name,
},
{ organizationId, workspaceId: project.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
})
);
const ZGetOrganizationsForSwitcherAction = z.object({
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -29,7 +30,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
} = layoutData;
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
return (
@@ -52,7 +52,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner
organization={organization}
environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount}
/>
)}
@@ -76,6 +75,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
@@ -2,41 +2,59 @@
import {
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Cog,
FoldersIcon,
Loader2,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
SettingsIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import {
getOrganizationsForSwitcherAction,
getProjectsForSwitcherAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import packageJson from "../../../../../package.json";
interface NavigationProps {
@@ -48,8 +66,31 @@ interface NavigationProps {
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
if (pathname.includes("/settings/")) {
return false;
}
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
if (accountSettingsPattern.test(pathname)) {
return false;
}
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const MainNavigation = ({
environment,
organization,
@@ -59,6 +100,10 @@ export const MainNavigation = ({
isFormbricksCloud,
isDevelopment,
publicDomain,
isMultiOrgEnabled,
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -68,7 +113,12 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState("");
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;
@@ -105,6 +155,7 @@ export const MainNavigation = ({
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
disabled: isMembershipPending || isBilling,
},
{
href: `/environments/${environment.id}/contacts`,
@@ -114,22 +165,17 @@ export const MainNavigation = ({
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
disabled: isMembershipPending || isBilling,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
isActive: pathname?.includes("/workspace"),
disabled: isMembershipPending || isBilling,
},
],
[t, environment.id, pathname, isFormbricksCloud]
[t, environment.id, pathname, isMembershipPending, isBilling]
);
const dropdownNavigation = [
@@ -152,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(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
@@ -167,7 +390,99 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases();
}, [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) => {
const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleProjectCreate = () => {
if (!hasInitializedProjects || isLoadingProjects) {
return;
}
if (projects.length >= organizationProjectsLimit) {
setOpenProjectLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${environment.id}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${environment.id}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
};
const switcherTriggerClasses = cn(
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
return (
<>
@@ -207,24 +522,24 @@ export const MainNavigation = ({
</div>
{/* Main Nav Switch */}
{!isBilling && (
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
)}
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
disabled={item.disabled}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
</div>
<div>
@@ -241,38 +556,217 @@ export const MainNavigation = ({
</Link>
)}
{/* User Switch */}
<div className="flex items-center">
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingProjects || isInitialProjectsLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects &&
!isInitialProjectsLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setProjects([]);
},
t("common.try_again")
)}
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === project.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleProjectCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
if (setting.hidden) return null;
return (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div
className={cn(
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} />
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
<button
type="button"
aria-label={isCollapsed ? t("common.account_settings") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-700">{t("common.account")}</p>
<p className="text-sm text-slate-500">{t("common.account")}</p>
</div>
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -281,8 +775,6 @@ export const MainNavigation = ({
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link
href={link.href}
@@ -296,7 +788,6 @@ export const MainNavigation = ({
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
@@ -319,6 +810,28 @@ export const MainNavigation = ({
</div>
</aside>
)}
{openProjectLimitModal && (
<ProjectLimitModal
open={openProjectLimitModal}
setOpen={setOpenProjectLimitModal}
buttons={projectLimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</>
);
};
@@ -1,6 +1,7 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps {
@@ -10,6 +11,8 @@ interface NavigationLinkProps {
children: React.ReactNode;
linkText: string;
isTextVisible: boolean;
disabled?: boolean;
disabledMessage?: string;
}
export const NavigationLink = ({
@@ -19,10 +22,34 @@ export const NavigationLink = ({
children,
linkText,
isTextVisible = true,
disabled = false,
disabledMessage,
}: NavigationLinkProps) => {
const tooltipText = disabled ? disabledMessage || linkText : linkText;
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const getColorClass = (baseClass: string) => {
if (disabled) {
return disabledClass;
}
return cn(baseClass, isActive ? activeClass : inactiveClass);
};
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
const label = (
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
);
return (
<>
@@ -30,35 +57,37 @@ export const NavigationLink = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li
className={cn(
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
isActive ? activeClass : inactiveClass
)}>
<Link href={href} className="flex items-center">
{children}
</Link>
<li 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">{linkText}</TooltipContent>
<TooltipContent side="right">{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<li
className={cn(
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
isActive ? activeClass : inactiveClass
)}>
<Link href={href} className="flex items-center">
{children}
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
</Link>
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
{disabled ? (
<Popover>
<PopoverTrigger asChild>
<div className="flex items-center">
{children}
{label}
</div>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{disabledMessage || linkText}
</PopoverContent>
</Popover>
) : (
<Link href={href} className="flex items-center">
{children}
{label}
</Link>
)}
</li>
)}
</>
@@ -31,7 +31,8 @@ export const TopControlBar = ({
isAccessControlAllowed,
membershipRole,
}: TopControlBarProps) => {
const { isMember } = getAccessFlags(membershipRole);
const { isMember, isBilling } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const { environment } = useEnvironment();
return (
@@ -49,6 +50,8 @@ export const TopControlBar = ({
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
@@ -25,6 +25,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps {
@@ -35,6 +36,7 @@ interface OrganizationBreadcrumbProps {
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
isMembershipPending: boolean;
}
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
@@ -56,6 +58,7 @@ export const OrganizationBreadcrumb = ({
isFormbricksCloud,
isMember,
isOwnerOrManager,
isMembershipPending,
}: OrganizationBreadcrumbProps) => {
const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
@@ -111,8 +114,12 @@ export const OrganizationBreadcrumb = ({
}
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentEnvironmentId) {
router.push(`/environments/${currentEnvironmentId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -142,7 +149,10 @@ export const OrganizationBreadcrumb = ({
id: "api-keys",
label: t("common.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",
@@ -160,7 +170,11 @@ export const OrganizationBreadcrumb = ({
id: "enterprise",
label: t("common.enterprise_license"),
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 +256,30 @@ export const OrganizationBreadcrumb = ({
{organizationSettings.map((setting) => {
return setting.hidden ? null : (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
hidden={setting.hidden}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
<div key={setting.id}>
{setting.disabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{setting.disabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
);
})}
</div>
@@ -18,6 +18,8 @@ interface ProjectAndOrgSwitchProps {
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean;
}
@@ -35,6 +37,8 @@ export const ProjectAndOrgSwitch = ({
isOwnerOrManager,
isAccessControlAllowed,
isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -50,6 +54,7 @@ export const ProjectAndOrgSwitch = ({
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
@@ -63,6 +68,8 @@ export const ProjectAndOrgSwitch = ({
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
)}
{showEnvironmentBreadcrumb && (
@@ -1,7 +1,7 @@
"use client";
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 { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
@@ -19,6 +19,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
@@ -33,6 +34,8 @@ interface ProjectBreadcrumbProps {
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
@@ -56,6 +59,8 @@ export const ProjectBreadcrumb = ({
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: ProjectBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
@@ -134,6 +139,10 @@ export const ProjectBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
];
const areProjectSettingsDisabled = isMembershipPending || isBilling;
const projectSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
@@ -143,9 +152,13 @@ export const ProjectBreadcrumb = ({
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
setIsProjectDropdownOpen(false);
router.push(targetPath);
});
};
@@ -198,7 +211,7 @@ export const ProjectBreadcrumb = ({
id="projectDropdownTrigger"
asChild>
<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>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
@@ -211,7 +224,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
@@ -247,7 +260,24 @@ export const ProjectBreadcrumb = ({
</DropdownMenuCheckboxItem>
))}
</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
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
@@ -264,13 +294,30 @@ export const ProjectBreadcrumb = ({
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
<div key={setting.id}>
{areProjectSettingsDisabled ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{projectSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
@@ -2,6 +2,8 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -25,6 +27,14 @@ const EnvLayout = async (props: {
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.project.id}
workspaceName={layoutData.project.name}
/>
)}
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/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 { session, organization } = await getEnvironmentAuth(params.environmentId);
@@ -12,11 +13,7 @@ const EnvironmentPage = async (props) => {
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
if (IS_FORMBRICKS_CLOUD) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
} else {
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
}
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
}
return redirect(`/environments/${params.environmentId}/surveys`);
@@ -1,10 +1,14 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props) => {
const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -17,15 +21,15 @@ const AccountSettingsLayout = async (props) => {
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user";
import { getUser, updateUser } from "@/lib/user/service";
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";
const ZUpdateNotificationSettingsAction = z.object({
@@ -14,24 +13,14 @@ const ZUpdateNotificationSettingsAction = z.object({
export const updateNotificationSettingsAction = authenticatedActionClient
.inputSchema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
);
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -16,8 +17,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
const newNotificationSettings: TUserNotificationSettings = {
alert: {} as Record<string, boolean>,
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
@@ -26,7 +27,8 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) {
for (const survey of environment.surveys) {
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]) ||
false; // check for legacy notification settings w/o "alerts" key
}
@@ -136,24 +138,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
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 params = await props.params;
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!memberships) {
throw new Error(t("common.membership_not_found"));
throw new ResourceNotFoundError(t("common.membership"), null);
}
if (user?.notificationSettings) {
@@ -6,21 +6,20 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { verifyUserPassword } from "@/lib/user/password";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
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 { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
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 {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -64,49 +63,39 @@ async function handleEmailUpdate({
}
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
)
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
})
);
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
)
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await requestPasswordReset(ctx.user, "profile");
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
})
);
@@ -1,30 +1,65 @@
"use client";
import type { Session } from "next-auth";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface DeleteAccountProps {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
}
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
accountDeletionError,
isMultiOrgEnabled,
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
requiresPasswordConfirmation,
}: Readonly<DeleteAccountProps>) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
? accountDeletionError[0]
: accountDeletionError;
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
hasShownAccountDeletionError.current
) {
return;
}
hasShownAccountDeletionError.current = true;
toast.error(t("environments.settings.profile.sso_identity_confirmation_failed"), {
id: "account-deletion-sso-confirmation-error",
});
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
globalThis.history.replaceState(null, "", url.toString());
}, [accountDeletionErrorCode, t]);
if (!session) {
return null;
}
@@ -32,6 +67,7 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -116,10 +116,14 @@ export const EditProfileDetailsForm = ({
setShowModal(true);
} else {
try {
await updateUserAction({
const result = await updateUserAction({
...data,
name: data.name.trim(),
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload();
form.reset(data);
@@ -1,12 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
import { getIsEmailUnique } from "./user";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -17,92 +11,12 @@ vi.mock("@formbricks/database", () => ({
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
@@ -1,42 +1,5 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
@@ -1,9 +1,16 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
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,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
PASSWORD_RESET_DISABLED,
} from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -14,10 +21,14 @@ import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslate();
const { environmentId } = params;
@@ -28,10 +39,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null;
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 requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
@@ -60,11 +72,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[
{
text: IS_FORMBRICKS_CLOUD
? t("common.start_free_trial")
? t("common.upgrade_plan")
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
},
{
text: t("common.learn_more"),
@@ -89,6 +101,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -22,8 +22,9 @@ export const OrganizationSettingsNavbar = ({
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember;
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager;
const isMembershipPending = membershipRole === undefined || loading;
const { t } = useTranslation();
const navigation = [
@@ -45,7 +46,10 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/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",
@@ -58,14 +62,18 @@ export const OrganizationSettingsNavbar = ({
id: "billing",
label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud || loading,
hidden: !isFormbricksCloud,
current: pathname?.includes("/billing"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
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"),
},
];
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -25,10 +26,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
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 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>
);
};
@@ -5,23 +5,24 @@ import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
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 { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps {
status: LicenseStatus;
status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date;
environmentId: string;
}
const getBadgeConfig = (
status: LicenseStatus,
status: TLicenseStatus,
t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) {
@@ -29,6 +30,11 @@ const getBadgeConfig = (
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "instance_mismatch":
return {
type: "error",
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
};
case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license":
@@ -40,10 +46,12 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({
status,
lastChecked,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -59,6 +67,8 @@ export const EnterpriseLicenseStatus = ({
if (result?.data) {
if (result.data.status === "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") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else {
@@ -86,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<div className="flex flex-wrap items-center gap-3">
<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>
<Button
type="button"
@@ -112,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
year: "numeric",
month: "short",
day: "numeric",
@@ -128,13 +143,25 @@ export const EnterpriseLicenseStatus = ({
</AlertDescription>
</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">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com">
hola@formbricks.com
</a>
<Trans
i18nKey="environments.settings.enterprise.questions_please_reach_out_to_email"
components={{
contactLink: (
<a
className="font-medium text-slate-700 underline hover:text-slate-900"
href="mailto:hola@formbricks.com"
/>
),
}}
/>
</p>
</div>
</SettingsCard>
@@ -3,15 +3,16 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
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 t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -93,15 +94,19 @@ const Page = async (props) => {
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
<>
<EnterpriseLicenseStatus
status={licenseState.status}
lastChecked={licenseState.lastChecked}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : (
<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">
@@ -168,7 +173,7 @@ const Page = async (props) => {
</p>
<Button asChild>
<Link
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
target="_blank"
rel="noopener noreferrer nofollow"
referrerPolicy="no-referrer">
@@ -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 { 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 { isInstanceAIConfigured } from "@/lib/ai/service";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getTranslate } from "@/lingodotdev/server";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
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({
organizationId: ZId,
@@ -27,26 +57,104 @@ export const updateOrganizationNameAction = authenticatedActionClient
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
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;
}
)
);
@@ -58,33 +166,26 @@ const ZDeleteOrganizationAction = z.object({
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging(
"deleted",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const t = await getTranslate(ctx.user.locale);
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
}
)
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
})
);
@@ -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) => {
const [inputValue, setInputValue] = useState("");
const { t } = useTranslation();
const handleInputChange = (e) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
}
};
@@ -1,5 +1,11 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
FB_LOGO_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -11,6 +17,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
@@ -26,7 +33,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -60,6 +67,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role}
/>
</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
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -69,6 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
fbLogoUrl={FB_LOGO_URL}
user={user}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
{isMultiOrgEnabled && (
<SettingsCard
@@ -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 { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props) => {
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const params = await props.params;
const { children } = props;
@@ -17,15 +18,15 @@ const Layout = async (props) => {
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
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>;
};
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};
@@ -3,25 +3,22 @@
import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
activeId,
}: SurveyAnalysisNavigationProps) => {
export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { environment } = useEnvironment();
const { survey } = useSurvey();
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const url = `/environments/${environment.id}/surveys/${survey.id}`;
const navigation = [
{
@@ -31,7 +28,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
{
@@ -41,7 +38,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
];
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useCallback, useContext, useState } from "react";
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import {
ElementOption,
ElementOptions,
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
export interface DateRange {
from: Date | undefined;
to?: Date | undefined;
to?: Date;
}
interface FilterDateContextProps {
@@ -41,6 +41,8 @@ interface FilterDateContextProps {
dateRange: DateRange;
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
resetState: () => void;
refreshAnalysisData: () => Promise<void>;
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
}
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
@@ -61,6 +63,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
from: undefined,
to: getTodayDate(),
});
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
const resetState = useCallback(() => {
setDateRange({
@@ -73,20 +76,43 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
}, []);
return (
<ResponseFilterContext.Provider
value={{
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
}}>
{children}
</ResponseFilterContext.Provider>
const refreshAnalysisData = useCallback(async () => {
await refreshHandlerRef.current?.();
}, []);
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
refreshHandlerRef.current = handler;
return () => {
if (refreshHandlerRef.current === handler) {
refreshHandlerRef.current = null;
}
};
}, []);
const contextValue = useMemo(
() => ({
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
refreshAnalysisData,
registerAnalysisRefreshHandler,
}),
[
dateRange,
refreshAnalysisData,
registerAnalysisRefreshHandler,
resetState,
selectedFilter,
selectedOptions,
]
);
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
};
const useResponseFilter = () => {
@@ -27,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>;
};
@@ -0,0 +1,22 @@
"use client";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
return (
<PageContentWrapper>
<PageHeader pageTitle="" />
<div className="flex h-9 animate-pulse gap-2">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="summary" />
</PageContentWrapper>
);
};
export default Loading;
@@ -2,6 +2,8 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
@@ -13,6 +15,7 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
interface ResponsePageProps {
@@ -46,8 +49,8 @@ export const ResponsePage = ({
const [page, setPage] = useState<number | null>(null);
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { t } = useTranslation();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
@@ -86,6 +89,34 @@ export const ResponsePage = ({
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
const refetchResponses = useCallback(async () => {
setIsFetchingFirstPage(true);
try {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
if (getResponsesActionResponse?.serverError) {
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
}
const freshResponses = getResponsesActionResponse?.data ?? [];
setResponses(freshResponses);
setPage(1);
setHasMore(freshResponses.length >= responsesPerPage);
} finally {
setIsFetchingFirstPage(false);
}
}, [filters, responsesPerPage, surveyId]);
useEffect(() => {
return registerAnalysisRefreshHandler(refetchResponses);
}, [refetchResponses, registerAnalysisRefreshHandler]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
}, [survey]);
@@ -134,6 +165,8 @@ export const ResponsePage = ({
}
};
fetchFilteredResponses();
// page is intentionally omitted to avoid refetching after the initial page setup.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return (
@@ -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 { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
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 { Button } from "@/modules/ui/components/button";
import {
@@ -96,8 +97,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
);
// Save settings to localStorage when they change
@@ -201,15 +202,21 @@ export const ResponseTable = ({
};
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
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
format,
filterCriteria: { responseIds },
});
@@ -300,7 +307,6 @@ export const ResponseTable = ({
<DataTableSettingsModal
open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table}
columnOrder={columnOrder}
handleDragEnd={handleDragEnd}
@@ -5,13 +5,14 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
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 { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
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 { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -34,6 +35,7 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -41,7 +43,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// 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 ElementHeader = () => (
<div className="flex items-center justify-between">
@@ -167,6 +169,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -218,6 +221,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -232,7 +236,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label];
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
metadataColumns.push({
accessorKey: "METADATA_" + label,
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
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> = {
accessorKey: "createdAt",
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
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 { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -38,7 +39,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source",
};
return translations[key] || key;
});
}) as unknown as TFunction;
describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => {
@@ -1,5 +1,4 @@
import { TFunction } from "i18next";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -9,6 +8,7 @@ import {
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
import { capitalize } from "@/lib/utils/object";
export const getAddressFieldLabel = (field: string, t: TFunction) => {
switch (field) {
@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
export const getMetadataValue = (
meta: TResponseMeta,
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];
};
@@ -0,0 +1,23 @@
"use client";
import { useTranslation } from "react-i18next";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
const Loading = () => {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.responses")} />
<div className="flex h-9 animate-pulse gap-1.5">
<div className="h-9 w-36 rounded-full bg-slate-200" />
<div className="h-9 w-36 rounded-full bg-slate-200" />
</div>
<SkeletonLoader type="responseTable" />
</PageContentWrapper>
);
};
export default Loading;
@@ -1,13 +1,18 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
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 { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
RESPONSES_PER_PAGE,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -17,31 +22,30 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
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 t = await getTranslate();
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),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
throw new Error(t("common.survey_not_found"));
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -50,10 +54,10 @@ const Page = async (props) => {
const organizationBilling = await getOrganizationBilling(organization.id);
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) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
@@ -65,8 +69,6 @@ const Page = async (props) => {
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -75,9 +77,10 @@ const Page = async (props) => {
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
<SurveyAnalysisNavigation activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -86,7 +89,7 @@ const Page = async (props) => {
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
locale={user.locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
@@ -4,10 +4,10 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
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 { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -65,57 +65,49 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
)
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
})
);
const ZGetEmailHtmlAction = z.object({
@@ -154,14 +146,16 @@ const ZGeneratePersonalLinksAction = z.object({
export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
organizationId,
access: [
{
type: "organization",
@@ -169,7 +163,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
projectId,
minPermission: "readWrite",
},
],
@@ -186,6 +180,18 @@ export const generatePersonalLinksAction = authenticatedActionClient
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
link_count: contactsResult.length,
},
{ organizationId, workspaceId: projectId }
);
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
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 { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
return (
formattedDate ??
t("common.invalid_date_with_value", {
value,
})
);
};
return (
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
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">
{response.contact ? (
<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">
{renderResponseValue(response.value)}
</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)}
</div>
</div>
@@ -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) {
setFilter(
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<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
className="w-full cursor-pointer hover:opacity-80"
key={group}
@@ -4,16 +4,13 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Confetti } from "@/modules/ui/components/confetti";
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
export const SuccessMessage = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
isQuotasAllowed: boolean;
}
const formatTime = (ttc) => {
const formatTime = (ttc: number) => {
const seconds = ttc / 1000;
let formattedValue;
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}
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}
/>
@@ -71,7 +71,7 @@ export const SummaryPage = ({
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
@@ -105,13 +105,13 @@ export const SummaryPage = ({
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
}, [fetchDisplays]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
@@ -131,13 +131,39 @@ export const SummaryPage = ({
}
}, [tab, loadInitialDisplays]);
const fetchSummary = useCallback(async () => {
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
const updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
if (updatedSurveySummary?.serverError) {
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
}
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
}, [dateRange, selectedFilter, survey, surveyId]);
const refreshSummary = useCallback(async () => {
setIsLoading(true);
try {
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
} finally {
setIsLoading(false);
}
}, [fetchSummary, loadInitialDisplays, tab]);
useEffect(() => {
return registerAnalysisRefreshHandler(refreshSummary);
}, [refreshSummary, registerAnalysisRefreshHandler]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter ||
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
(!dateRange || (!dateRange.from && !dateRange.to));
if (initialSurveySummary && hasNoFilters) {
@@ -145,21 +171,11 @@ export const SummaryPage = ({
return;
}
const fetchSummary = async () => {
const fetchFilteredSummary = async () => {
setIsLoading(true);
try {
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
await fetchSummary();
} catch (error) {
console.error(error);
} finally {
@@ -167,8 +183,8 @@ export const SummaryPage = ({
}
};
fetchSummary();
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
fetchFilteredSummary();
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -1,18 +1,18 @@
"use client";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
@@ -23,8 +23,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -33,6 +31,7 @@ interface SurveyAnalysisCTAProps {
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
isStorageConfigured: boolean;
enterpriseLicenseRequestFormUrl: string;
}
interface ModalState {
@@ -41,8 +40,6 @@ interface ModalState {
}
export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
user,
publicDomain,
@@ -51,6 +48,7 @@ export const SurveyAnalysisCTA = ({
isContactsEnabled,
isFormbricksCloud,
isStorageConfigured,
enterpriseLicenseRequestFormUrl,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -63,9 +61,12 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const { organizationId, project } = useEnvironment();
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const { refreshAnalysisData } = useResponseFilter();
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -77,7 +78,7 @@ export const SurveyAnalysisCTA = ({
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(globalThis.location.search);
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
@@ -112,9 +113,12 @@ export const SurveyAnalysisCTA = ({
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
const singleUseLinkParams = await refreshSingleUseId();
if (singleUseLinkParams) {
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
if (singleUseLinkParams.suToken) {
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
}
}
}
@@ -128,7 +132,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
@@ -148,6 +151,25 @@ export const SurveyAnalysisCTA = ({
};
const iconActions = [
{
icon: RefreshCcwIcon,
tooltip: t("common.refresh"),
onClick: async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
await refreshAnalysisData();
toast.success(t("common.data_refreshed_successfully"));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
} finally {
setIsRefreshing(false);
}
},
disabled: isRefreshing,
isVisible: true,
},
{
icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -184,7 +206,7 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown environment={environment} survey={survey} />
<SurveyStatusDropdown />
)}
<IconBar actions={iconActions} />
@@ -214,9 +236,10 @@ export const SurveyAnalysisCTA = ({
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
<SuccessMessage />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog

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