Compare commits

...

114 Commits

Author SHA1 Message Date
Piyush Gupta 4f276f0095 feat: personalized survey links for segment of users endpoint (#5032)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-08 05:54:27 +00:00
Dhruwang Jariwala 81fc97c7e9 fix: Add Cache-Control to allowed CORS headers (#5252) 2025-04-07 14:47:02 +00:00
Matti Nannt 785c5a59c6 chore: make mock passwords more obvious to test suites (#5240) 2025-04-07 12:40:40 +00:00
Piyush Gupta 25ecfaa883 fix: formbricks version on localhost (#5250) 2025-04-07 10:42:13 +00:00
Anshuman Pandey 38e2c019fa fix: ios package sonarqube fixes (#5249) 2025-04-07 08:48:56 +00:00
victorvhs017 15878a4ac5 chore: Refactored the Turnstile next public env variable and added test files (#4997)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-07 06:07:39 +00:00
Matti Nannt 9802536ded chore: upgrade demo app to tailwind v4 (#5237)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-07 05:40:10 +00:00
victorvhs017 2c7f92a4d7 feat: user endpoints (#5232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 06:06:18 +00:00
Piyush Gupta c653841037 chore: block signin with SSO when user is not found (#5233)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 04:22:53 +00:00
Matti Nannt ec314c14ea fix: failing e2e test (#5234) 2025-04-05 14:20:22 +02:00
victorvhs017 c03e60ac0b feat: organization endpoints (#5076)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-05 13:54:21 +02:00
Dhruwang Jariwala cbf2343143 feat: lastLoginAt to user model (#5216) 2025-04-05 13:22:38 +02:00
Dhruwang Jariwala 9d9b3ac543 chore: added isActive to user model (#5211)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-05 12:22:45 +02:00
Matti Nannt 591b35a70b fix: upgrade npm dependencies with high security risk (#5221) 2025-04-05 06:04:01 +02:00
Piyush Gupta f0c7b881d3 fix: don't allow spaces as "other" values in select questions (#5224) 2025-04-04 08:01:26 +00:00
dependabot[bot] 3fd5515db1 chore(deps): bump SonarSource/sonarqube-scan-action from 4.2.1 to 5.1.0 (#5104)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 05:03:40 +02:00
Matti Nannt f32401afd6 chore: update vite & vitest dependency versions (#5217) 2025-04-04 03:40:21 +02:00
Dhruwang Jariwala 1b9d91f1e8 chore: Api keys to org level (#5044)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-03 14:59:42 +02:00
Matti Nannt 1f039d707c chore: update root npm dependencies (#5208) 2025-04-03 06:40:29 +02:00
Dhruwang Jariwala 6671d877ad fix: skip button label validation for required nps and rating questions (#5153) 2025-04-02 09:53:25 +00:00
Matti Nannt 2867c95494 chore: update SHARE_RATE_LIMIT to 50 request per 5 minute (#5194)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2025-04-02 09:48:59 +00:00
Johannes aa55cec060 fix: bulk member invite and table layout (#5209) 2025-04-02 09:32:01 +00:00
Matti Nannt dfb6c4cd9e chore: update demo app dependencies (#5207) 2025-04-02 06:34:15 +00:00
Dhruwang Jariwala a9082f66e8 fix: (Security) implement HSTS (#5206) 2025-04-02 05:38:33 +00:00
Dhruwang Jariwala bf39b0fbfb fix: added cache no-store when formbricksDebug is enabled (#5197)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-02 05:19:27 +00:00
Dhruwang Jariwala e347f2179a fix: consent/cta back button issue (#5201) 2025-04-02 02:29:53 +00:00
Matti Nannt d4f155b6bc chore: update storybook app dependencies (#5195) 2025-04-01 19:39:45 +02:00
Matti Nannt da001834f5 chore: remove unused tailwind import from mobile SDK webviews (#5198) 2025-04-01 12:59:57 +00:00
Anshuman Pandey f54352dd82 chore: changes storage cache to 5 minutes (#5196) 2025-04-01 07:25:17 +00:00
Matti Nannt 0fba0fae73 chore: remove posthog provider from top layout (#5169) 2025-04-01 06:24:17 +00:00
Anshuman Pandey 406ec88515 fix: adding back hidden fields for backwards compatibility (#5163) 2025-04-01 05:20:30 +00:00
Matti Nannt b97957d166 chore(infra): increase ressource limits to 1 cpu & 1Gi mem (#5192) 2025-04-01 04:50:55 +00:00
Matti Nannt 655ad6b9e0 docs: fix response client api endpoint is missing environmentId (#5161) 2025-03-31 12:14:44 +02:00
Anshuman Pandey f5ce42fc2d feat: api for uploading contacts in bulk (#5053)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-30 07:44:17 +00:00
dependabot[bot] 709cdf260d chore(deps): bump react-day-picker from 9.4.4 to 9.6.3 (#5149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-29 06:29:05 +00:00
Piyush Gupta 5c583028e0 feat: adds second domain (#4989)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-28 17:22:17 +00:00
Matti Nannt c70008d1be chore: remove unused cron github actions (#5151) 2025-03-28 12:13:23 +01:00
Piyush Jain 13fa716fe8 chore(eks): add AmazonSSMManagedInstanceCore policy (#5152) 2025-03-28 08:57:56 +00:00
victorvhs017 c3af5b428f feat: added the roles endpoint, documentation, unity and e2e tests (#5068)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-28 04:53:39 +00:00
Matti Nannt 40e2f28e94 chore: add dependabot config (#5084) 2025-03-28 04:02:29 +01:00
victorvhs017 2964f2e079 chore: Refactored the Sentry next public env variable and added test files (#4979)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-27 13:04:25 +00:00
Jakob Schott e1a5291123 fix: unify alert component (#5002)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-03-27 12:46:56 +00:00
Piyush Jain ef41f35209 fix: rds (#5078) 2025-03-27 12:30:49 +01:00
Jakob Schott 2f64b202c1 fix: billing modal translation (#5079) 2025-03-27 10:50:24 +00:00
Piyush Gupta 2500c739ae fix: next-auth inactive session timeout changed 30days -> 1hr (#5066) 2025-03-27 09:54:35 +00:00
Matti Nannt 63a9a6135b fix: github issues url required login (#5077) 2025-03-27 04:42:57 +01:00
Dhruwang Jariwala 417005c6e9 fix: docker image not building (#5069)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-27 02:32:03 +00:00
Piyush Jain cd1739c901 chore: updates enable PR comments for terraform plan (#5073) 2025-03-27 01:40:24 +00:00
Piyush Jain 709917eb8f chore: fix OneLeet compliance and update self-hosting docs (#5045)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-26 10:37:56 +01:00
Johannes 3ba70122d5 docs: update hidden field docs (#5067) 2025-03-26 01:45:31 -07:00
Dhruwang Jariwala 5ff025543e fix: static ttf in link survey preview (#5054) 2025-03-26 05:42:30 +00:00
Anshuman Pandey 896d5bad12 fix: adds network checks for the react-native sdk (#5034)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-26 04:51:42 +00:00
Jakob Schott e9dbaa3c28 fix: survey id in summary (#5056) 2025-03-26 02:24:58 +00:00
dependabot[bot] d352d03071 chore(deps-dev): bump the npm_and_yarn group across 2 directories with 1 update (#5062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-26 02:09:46 +00:00
victorvhs017 ebefe775bb fix: updated ttl property on cache-handler (#5065) 2025-03-26 01:53:59 +00:00
Anshuman Pandey 0852a961cc fix: adds unit tests for tsx files (#5001) 2025-03-25 16:58:47 +00:00
victorvhs017 46f06f4c0e feat: Added Webhooks in Management API V2 (#4949)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-25 14:28:44 +00:00
Matti Nannt afb39e4aba docs: update license page (#5061) 2025-03-25 15:09:13 +01:00
Anshuman Pandey 2c6a90f82b fix: storage api endpoint openapi spec (#5057) 2025-03-25 12:16:35 +00:00
Dhruwang Jariwala e35f732e48 fix: Remove hardcoded pg url (#4878) 2025-03-25 08:36:18 +00:00
Matti Nannt ec8b17dee2 chore: use github bug report form instead of formbricks form (#5055) 2025-03-25 08:44:32 +01:00
Matti Nannt 947bc1a233 fix: Github release action unable to access secrets (#5052) 2025-03-25 04:23:49 +01:00
Johannes 7050caa2f3 fix: tweak password reset ux (#5049) 2025-03-24 08:32:11 -07:00
Johannes c4fd1a0a54 docs: add EE feature to list (#5051) 2025-03-24 08:31:42 -07:00
victorvhs017 4de5f5c490 chore: Refactored the Posthog next public env variable and added test files (#4961)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-24 14:45:33 +00:00
Piyush Gupta b3f336c959 fix: invite user bug (#5043)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-24 13:59:31 +00:00
victorvhs017 010784c2b2 chore: added new script with onload (#4987)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-24 12:12:37 +00:00
victorvhs017 306f654617 fix: add membership checks in remaining routes (#5026) 2025-03-24 04:49:04 -07:00
Anshuman Pandey 60d0563487 fix: adds missing storage api docs and fixes api key auth docs (#5031) 2025-03-24 06:43:57 +00:00
victorvhs017 777210ec42 fix: refactored the code to create a new link on click for single-use link surveys (#5038)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-24 05:39:17 +00:00
Piyush Jain 8649522b5b chore(actions): Update github actions to follow new release pattern (#5037) 2025-03-24 04:51:32 +01:00
Matti Nannt 71ebde06f4 docs: add prometheus to monitoring docs (#5042) 2025-03-24 04:15:31 +01:00
Peter Pesti-Varga d98eb5b46f fix: Trust server in the iOS webview to allow to load the survey package (#5024) 2025-03-23 01:45:53 +01:00
dependabot[bot] 6a2a8b74c8 chore(deps): bump the npm_and_yarn group across 3 directories with 1 update (#5035)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-22 04:24:15 +01:00
Harsh Shrikant Bhat 43d5d3d719 chore: small email tweaks (#5019) 2025-03-21 06:14:56 -07:00
Piyush Gupta 5527f184b7 feat: adds configurable logging (#4914)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-21 06:09:13 -07:00
Piyush Jain 7dd5cf8b6e release: pre steps switch from vercel to aws (#5030) 2025-03-21 08:36:53 +01:00
Piyush Gupta aec697f5b9 fix: role escalation in org settings (#4901)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-21 05:38:51 +00:00
Piyush Jain aa2588dd89 chore(terraform): fix terraform certs (#5023) 2025-03-20 09:08:17 +00:00
victorvhs017 ed886e1794 fix: add membership checks in [environmentId] route (#5020)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-03-20 02:07:19 -07:00
Dhruwang Jariwala 452709dec7 fix: recall in email embed (#4971)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-03-20 05:22:42 +00:00
Dhruwang Jariwala a5cac35cfd fix: single use link generation (#5004) 2025-03-20 04:31:36 +00:00
Peter Pesti-Varga 3ee8485ef0 fix: Android build changes + close survey window on js exception (#5016) 2025-03-19 09:11:31 -07:00
Dhruwang Jariwala 673f61be17 fix: layout breaking when adding note to response (#5007) 2025-03-19 05:22:24 -07:00
Piyush Jain db86247510 chore(observability): add observability tools permissions (#5003) 2025-03-19 09:57:02 +00:00
Harsh Shrikant Bhat 090f6eef71 docs: add enterprise hint for all EE features in docs (#5000)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-03-19 01:34:53 -07:00
Matti Nannt 214d18616f feat: personalized survey links (#4870)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-03-19 07:30:39 +00:00
Piyush Gupta 3b126291a6 docs: removed XM & Survey -> SAML SSO (#4999) 2025-03-19 07:06:46 +00:00
Piyush Jain 55a230e127 chore: updates to aws cloud resources (#4996) 2025-03-18 19:01:44 +01:00
Anshuman Pandey 2a107ece7f chore: js-core sdk refactor (#4815)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-03-18 15:58:50 +00:00
victorvhs017 7a3ef93a18 chore: Refactored the intercom next public env variable and added test files (#4960) 2025-03-18 15:04:08 +00:00
Anshuman Pandey 6255c9baad fix: handling invalid csv files (#4991) 2025-03-18 14:30:28 +00:00
Piyush Jain c322a963ab fix(helm-chart): missing envFrom when using secret.enabled (#4992) 2025-03-18 15:41:16 +01:00
Paribesh Nepal b1e8cb5a07 feat: added qr code feature (#4951) 2025-03-18 07:21:32 -07:00
Harsh Shrikant Bhat a391089efc docs: Missing page descriptions. (#4980) 2025-03-18 07:20:13 -07:00
victorvhs017 1894bbe4f7 feat: add custom TTL for cache records (#4912) 2025-03-18 12:33:52 +00:00
Peter Pesti-Varga 07dba90679 fix: Android build fixes (#4984) 2025-03-18 13:14:25 +01:00
Matti Nannt ca5ea315d6 chore: determine formbricks version on release (#4985) 2025-03-18 11:49:12 +01:00
Piyush Gupta 646fe9c67f feat: optional cron jobs check (#4966) 2025-03-18 10:13:31 +00:00
StepSecurity Bot 6a123a2399 fix: Harden GitHub Actions (#4982)
Signed-off-by: StepSecurity Bot <bot@stepsecurity.io>
2025-03-18 11:23:10 +01:00
Piyush Jain 39aa9f0941 chore(infra-updates): updates and fixes (#4976) 2025-03-17 17:45:32 +00:00
Jakob Schott 625a4dcfae fix: changed 'Download example CSV'-link to a button (#4975) 2025-03-17 16:51:43 +00:00
Harsh Shrikant Bhat 7971681d02 docs: Remove duplicate titles for better SEO (#4962) 2025-03-17 09:50:17 -07:00
Johannes 3dea241d7a docs: tweak docs for sso (#4974) 2025-03-17 06:46:54 -07:00
Peter Pesti-Varga e5ce6532f5 fix: Fix Android build setting (#4967) 2025-03-17 13:13:05 +01:00
victorvhs017 aa910ca3f0 fix: updated docker file with redis and minio containers (#4909) 2025-03-17 09:33:02 +00:00
Piyush Gupta c2d237a99a fix: google sheet integration error message (#4899) 2025-03-16 16:10:51 +00:00
Piyush Jain a371bdaedd chore(terraform): fix (#4963) 2025-03-15 13:32:05 +00:00
Piyush Jain dbbd77a8eb chore(env): add new env variables (#4959) 2025-03-15 12:20:07 +00:00
Matti Nannt c28de7c079 chore: prepare 3.4.0 release (#4950) 2025-03-13 20:38:32 +01:00
Matti Nannt 05f1068e01 chore: prepare 3.3.2 release (#4930) 2025-03-13 20:35:51 +01:00
Matti Nannt 7103ec9877 fix: survey preview stuck in sending (#4941) 2025-03-13 20:34:45 +01:00
Johannes 9cd7a25343 fix: fix except last (#4942) 2025-03-13 14:13:23 +00:00
IllimarR 2d028d18e5 feat: possibility to set mail from name (#4864)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-03-13 05:50:15 -07:00
859 changed files with 42428 additions and 12933 deletions
+16 -3
View File
@@ -25,6 +25,9 @@ NEXTAUTH_SECRET=
# You can use: `openssl rand -hex 32` to generate a secure one # You can use: `openssl rand -hex 32` to generate a secure one
CRON_SECRET= CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
############## ##############
# DATABASE # # DATABASE #
############## ##############
@@ -39,6 +42,7 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
# See optional configurations below if you want to disable these features. # See optional configurations below if you want to disable these features.
MAIL_FROM=noreply@example.com MAIL_FROM=noreply@example.com
MAIL_FROM_NAME=Formbricks
SMTP_HOST=localhost SMTP_HOST=localhost
SMTP_PORT=1025 SMTP_PORT=1025
# Enable SMTP_SECURE_ENABLED for TLS (port 465) # Enable SMTP_SECURE_ENABLED for TLS (port 465)
@@ -76,6 +80,9 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled) # Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0 S3_FORCE_PATH_STYLE=0
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
# SURVEY_URL=https://survey.example.com
##################### #####################
# Disable Features # # Disable Features #
##################### #####################
@@ -96,6 +103,9 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account. # Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1 # INVITE_DISABLED=1
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
# DOCKER_CRON_ENABLED=1
########## ##########
# Other # # Other #
########## ##########
@@ -107,7 +117,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS= IMPRINT_ADDRESS=
# Configure Turnstile in signup flow # Configure Turnstile in signup flow
# NEXT_PUBLIC_TURNSTILE_SITE_KEY= # TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY= # TURNSTILE_SECRET_KEY=
# Configure Github Login # Configure Github Login
@@ -184,7 +194,9 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY= UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided) # The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# REDIS_URL=redis://localhost:6379 # You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL: # REDIS_HTTP_URL:
@@ -201,9 +213,10 @@ UNKEY_ROOT_KEY=
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID= # AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
# AI_AZURE_LLM_DEPLOYMENT_ID= # AI_AZURE_LLM_DEPLOYMENT_ID=
# NEXT_PUBLIC_INTERCOM_APP_ID= # INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY= # INTERCOM_SECRET_KEY=
# Enable Prometheus metrics # Enable Prometheus metrics
# PROMETHEUS_ENABLED= # PROMETHEUS_ENABLED=
# PROMETHEUS_EXPORTER_PORT= # PROMETHEUS_EXPORTER_PORT=
@@ -57,9 +57,6 @@ runs:
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32) RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash shell: bash
+84
View File
@@ -0,0 +1,84 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
directory: "/" # Root package.json
schedule:
interval: "weekly"
versioning-strategy: increase
# Apps directory packages
- package-ecosystem: "npm"
directory: "/apps/demo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/demo-react-native"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/storybook"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/web"
schedule:
interval: "weekly"
# Packages directory
- package-ecosystem: "npm"
directory: "/packages/database"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/lib"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/types"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-eslint"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-prettier"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-typescript"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/js-core"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/surveys"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/logger"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
@@ -1,33 +0,0 @@
name: Cron - Survey status update
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs "At 00:00." (see https://crontab.guru)
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ env.APP_URL }}/api/cron/survey-status \
-X POST \
-H 'content-type: application/json' \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail
-33
View File
@@ -1,33 +0,0 @@
name: Cron - Weekly summary
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
- cron: "0 8 * * 1"
permissions:
contents: read
jobs:
cron-weeklySummary:
permissions:
contents: read
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
with:
egress-policy: audit
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ env.APP_URL }}/api/cron/weekly-summary \
-X POST \
-H 'content-type: application/json' \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail
@@ -0,0 +1,64 @@
name: Formbricks Cloud Deployment
on:
workflow_dispatch:
inputs:
VERSION:
description: 'The version of the Docker image to release'
required: true
type: string
REPOSITORY:
description: 'The repository to use for the Docker image'
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
workflow_call:
inputs:
VERSION:
description: 'The version of the Docker image to release'
required: true
type: string
REPOSITORY:
description: 'The repository to use for the Docker image'
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
permissions:
id-token: write
contents: write
jobs:
helmfile-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
- name: Setup Cluster Access
run: |
aws eks update-kubeconfig --name formbricks-prod-eks --region eu-central-1
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_S3_BUCKET: ${{ secrets.FORMBRICKS_S3_BUCKET }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
helmfile-args: apply
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
+1 -1
View File
@@ -142,7 +142,7 @@ jobs:
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
if: failure() if: failure()
with: with:
name: app-logs name: app-logs
+33
View File
@@ -0,0 +1,33 @@
name: Build, release & deploy Formbricks images
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
docker-build:
name: Build & release stable docker image
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
helm-chart-release:
name: Release Helm Chart
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
- docker-build
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs:
- docker-build
- helm-chart-release
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
-67
View File
@@ -1,67 +0,0 @@
name: Prepare release
run-name: Prepare release ${{ inputs.next_version }}
on:
workflow_dispatch:
inputs:
next_version:
required: true
type: string
description: "Version name"
permissions:
contents: write
pull-requests: write
jobs:
prepare_release:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Bump version
run: |
cd apps/web
pnpm version ${{ inputs.next_version }} --no-workspaces-update
- name: Commit changes and create a branch
run: |
branch_name="release-v${{ inputs.next_version }}"
git checkout -b "$branch_name"
git add .
git commit -m "chore: release v${{ inputs.next_version }}"
git push origin "$branch_name"
- name: Create pull request
run: |
gh pr create \
--base main \
--head "release-v${{ inputs.next_version }}" \
--title "chore: bump version to v${{ inputs.next_version }}" \
--body "This PR contains the changes for the v${{ inputs.next_version }} release."
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -15,7 +15,6 @@ env:
IMAGE_NAME: ${{ github.repository }}-experimental IMAGE_NAME: ${{ github.repository }}-experimental
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions: permissions:
contents: read contents: read
@@ -80,6 +79,9 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
+24 -5
View File
@@ -6,10 +6,11 @@ name: Docker Release to Github
# documentation. # documentation.
on: on:
workflow_dispatch: workflow_call:
push: outputs:
tags: VERSION:
- "v*" description: release version
value: ${{ jobs.build.outputs.VERSION }}
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
@@ -18,7 +19,6 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
permissions: permissions:
contents: read contents: read
@@ -33,6 +33,9 @@ jobs:
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write id-token: write
outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
@@ -42,6 +45,19 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI - name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -83,6 +99,9 @@ jobs:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
-54
View File
@@ -1,54 +0,0 @@
name: Release on Dockerhub
on:
push:
tags:
- "v*"
permissions:
contents: read
jobs:
release-image-on-dockerhub:
name: Release on Dockerhub
permissions:
contents: read
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout Repo
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- name: Log in to Docker Hub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
- name: Get Release Tag
id: extract_release_tag
run: |
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
- name: Build and push Docker image
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
${{ secrets.DOCKER_USERNAME }}/formbricks:latest
+54
View File
@@ -0,0 +1,54 @@
name: Publish Helm Chart
on:
workflow_call:
inputs:
VERSION:
description: 'The version of the Helm chart to release'
required: true
type: string
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
with:
version: latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
helm package ./helm-chart
- name: Push Helm chart to GitHub Container Registry
run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
+3 -7
View File
@@ -23,10 +23,10 @@ jobs:
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 20.x - name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 20.x node-version: 22.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
@@ -46,13 +46,9 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
run: | run: |
cd apps/web
pnpm test:coverage pnpm test:coverage
cd ../../
# The Vitest coverage config is in your vite.config.mts
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -0,0 +1,79 @@
name: 'Terraform'
on:
workflow_dispatch:
# TODO: enable it back when migration is completed.
push:
branches:
- main
paths:
- "infra/terraform/**"
pull_request:
branches:
- main
paths:
- "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
terraform:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1"
- name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- name: Terraform Format
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true
working-directory: infra/terraform
- name: Terraform Init
id: init
run: terraform init
working-directory: infra/terraform
- name: Terraform Validate
id: validate
run: terraform validate
working-directory: infra/terraform
- name: Terraform Plan
id: plan
run: terraform plan -out .planfile
working-directory: infra/terraform
- name: Post PR comment
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
with:
token: ${{ github.token }}
planfile: .planfile
working-directory: "infra/terraform"
- name: Terraform Apply
id: apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile
working-directory: "infra/terraform"
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
images=($(yq eval '.services.*.image' packages/database/docker-compose.yml)) images=($(yq eval '.services.*.image' docker-compose.dev.yml))
pull_image() { pull_image() {
docker pull "$1" docker pull "$1"
+1 -1
View File
@@ -18,7 +18,7 @@
"expo-status-bar": "2.0.1", "expo-status-bar": "2.0.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.6", "react-native": "0.78.2",
"react-native-webview": "13.12.5" "react-native-webview": "13.12.5"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element { export function Sidebar(): React.JSX.Element {
return ( return (
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5"> <div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<nav <nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto" className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar"> aria-label="Sidebar">
@@ -41,7 +41,7 @@ export function Sidebar(): React.JSX.Element {
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6" "group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
)} )}
aria-current={item.current ? "page" : undefined}> aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" /> <item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
{item.name} {item.name}
</a> </a>
))} ))}
+23 -3
View File
@@ -1,3 +1,23 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; @plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
+8 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@formbricks/demo", "name": "@formbricks/demo",
"version": "0.1.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"clean": "rimraf .turbo node_modules .next", "clean": "rimraf .turbo node_modules .next",
@@ -12,10 +12,14 @@
}, },
"dependencies": { "dependencies": {
"@formbricks/js": "workspace:*", "@formbricks/js": "workspace:*",
"lucide-react": "0.468.0", "@tailwindcss/forms": "0.5.9",
"next": "15.1.2", "@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0",
"tailwindcss": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
+124 -22
View File
@@ -9,6 +9,12 @@ declare const window: Window;
export default function AppPage(): React.JSX.Element { export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false); const [darkMode, setDarkMode] = useState(false);
const router = useRouter(); const router = useRouter();
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userAttributes = {
"Attribute 1": "one",
"Attribute 2": "two",
"Attribute 3": "three",
};
useEffect(() => { useEffect(() => {
if (darkMode) { if (darkMode) {
@@ -33,18 +39,9 @@ export default function AppPage(): React.JSX.Element {
addFormbricksDebugParam(); addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"; void formbricks.setup({
const userInitAttributes = {
language: "de",
"Init Attribute 1": "eight",
"Init Attribute 2": "two",
};
void formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST, appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
userId,
attributes: userInitAttributes,
}); });
} }
@@ -99,7 +96,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300"> <p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p> </p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority /> <Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300"> <div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p> <p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
@@ -126,19 +123,19 @@ export default function AppPage(): React.JSX.Element {
<div className="md:grid md:grid-cols-3"> <div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900"> <div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white"> <h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app Set a user ID / pull data from Formbricks app
</h3> </h3>
<p className="text-slate-700 dark:text-slate-300"> <p className="text-slate-700 dark:text-slate-300">
On formbricks.reset() the local state will <strong>be deleted</strong> and formbricks gets{" "} On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
<strong>reinitialized</strong>. the local state gets <strong>updated with the user state</strong>.
</p> </p>
<button <button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600" className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
type="button" type="button"
onClick={() => { onClick={() => {
void formbricks.reset(); void formbricks.setUserId(userId);
}}> }}>
Reset Set user ID
</button> </button>
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
@@ -158,7 +155,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "} This button sends a{" "}
<a <a
href="https://formbricks.com/docs/actions/no-code" href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500" className="underline dark:text-blue-500"
target="_blank"> target="_blank">
@@ -166,7 +163,7 @@ export default function AppPage(): React.JSX.Element {
</a>{" "} </a>{" "}
as long as you created it beforehand in the Formbricks App.{" "} as long as you created it beforehand in the Formbricks App.{" "}
<a <a
href="https://formbricks.com/docs/actions/no-code" href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -175,6 +172,7 @@ export default function AppPage(): React.JSX.Element {
</p> </p>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
<div> <div>
<button <button
@@ -190,7 +188,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "} This button sets the{" "}
<a <a
href="https://formbricks.com/docs/attributes/custom-attributes" href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -215,7 +213,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "} This button sets the{" "}
<a <a
href="https://formbricks.com/docs/attributes/custom-attributes" href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -240,7 +238,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-xs text-slate-700 dark:text-slate-300"> <p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "} This button sets the{" "}
<a <a
href="https://formbricks.com/docs/attributes/identify-users" href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline dark:text-blue-500"> className="underline dark:text-blue-500">
@@ -250,6 +248,110 @@ export default function AppPage(): React.JSX.Element {
</p> </p>
</div> </div>
</div> </div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttributes(userAttributes);
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Multiple Attributes
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user attributes
</a>{" "}
to &apos;one&apos;, &apos;two&apos;, &apos;three&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setLanguage("de");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Language to &apos;de&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
language
</a>{" "}
to &apos;de&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.track("code");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.logout();
}}>
Logout
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button logs out the user and syncs the local state with Formbricks. (Only works if a
userId is set)
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+1 -2
View File
@@ -1,6 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, "@tailwindcss/postcss": {},
autoprefixer: {},
}, },
}; };
-13
View File
@@ -1,13 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};
+20 -20
View File
@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": { "dependencies": {
"eslint-plugin-react-refresh": "0.4.16", "eslint-plugin-react-refresh": "0.4.19",
"react": "19.0.0", "react": "19.1.0",
"react-dom": "19.0.0" "react-dom": "19.1.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "3.2.2", "@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.4.7", "@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.4.7", "@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.4.7", "@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.4.7", "@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.4.7", "@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.4.7", "@storybook/blocks": "8.6.12",
"@storybook/react": "8.4.7", "@storybook/react": "8.6.12",
"@storybook/react-vite": "8.4.7", "@storybook/react-vite": "8.6.12",
"@storybook/test": "8.4.7", "@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.18.0", "@typescript-eslint/eslint-plugin": "8.29.0",
"@typescript-eslint/parser": "8.18.0", "@typescript-eslint/parser": "8.29.0",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.1", "esbuild": "0.25.2",
"eslint-plugin-storybook": "0.11.1", "eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"storybook": "8.4.7", "storybook": "8.6.12",
"tsup": "8.3.5", "tsup": "8.4.0",
"vite": "6.0.9" "vite": "6.2.4"
} }
} }
+36 -14
View File
@@ -24,17 +24,27 @@ RUN corepack enable
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
# Set hardcoded environment variables # BuildKit secret handling without hardcoded fallback values
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public" # This approach relies entirely on secrets passed from GitHub Actions
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime" RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime" echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime" echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
ARG NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit # Increase Node.js memory limit as a regular build argument
# ENV NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS}
# Set the working directory # Set the working directory
WORKDIR /app WORKDIR /app
@@ -53,8 +63,11 @@ RUN touch apps/web/.env
# Install the dependencies # Install the dependencies
RUN pnpm install RUN pnpm install
# Build the project # Build the project using our secret reader script
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web... # This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version # Extract Prisma version
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
@@ -85,6 +98,8 @@ COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
# Copy Prisma-specific generated files # Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
@@ -93,14 +108,16 @@ COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_mod
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt . COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs COPY /docker/cronjobs /app/docker/cronjobs
# Copy only @paralleldrive/cuid2 and @noble/hashes # Copy required dependencies
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2 COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN npm install -g tsx typescript prisma RUN npm install -g tsx typescript prisma pino-pretty
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
# USER nextjs # USER nextjs
# Prepare volume for uploads # Prepare volume for uploads
@@ -111,7 +128,12 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD supercronic -quiet /app/docker/cronjobs & \ CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \ (cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \ (cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js exec node apps/web/server.js
@@ -0,0 +1,103 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
// Mock react-hot-toast so we can assert that a success message is shown
vi.mock("react-hot-toast", () => ({
__esModule: true,
default: {
success: vi.fn(),
},
}));
// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy.
beforeAll(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
writable: true,
value: {
// Using a mockResolvedValue resolves the promise as writeText is async.
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
describe("OnboardingSetupInstructions", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// Provide some default props for testing
const defaultProps = {
environmentId: "env-123",
webAppUrl: "https://example.com",
channel: "app" as const, // Assuming channel is either "app" or "website"
widgetSetupCompleted: false,
};
test("renders HTML tab content by default", () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
// Since the default active tab is "html", we check for a unique text
expect(
screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i)
).toBeInTheDocument();
// The HTML snippet contains a marker comment
expect(screen.getByText("START")).toBeInTheDocument();
// Verify the "Copy Code" button is present
expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument();
});
test("renders NPM tab content when selected", async () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const user = userEvent.setup();
// Click on the "NPM" tab to switch views.
const npmTab = screen.getByText("NPM");
await user.click(npmTab);
// Check that the install commands are present
expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument();
expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument();
// Verify the "Read Docs" link has the correct URL (based on channel prop)
const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i });
expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides");
});
test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const user = userEvent.setup();
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
// Click the "Copy Code" button
const copyButton = screen.getByRole("button", { name: /common.copy_code/i });
await user.click(copyButton);
// Ensure navigator.clipboard.writeText was called.
expect(writeTextSpy).toHaveBeenCalled();
const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string;
// Check that the pasted snippet contains the expected environment values
expect(writtenText).toContain('var appUrl = "https://example.com"');
expect(writtenText).toContain('var environmentId = "env-123"');
// Verify that a success toast was shown
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("renders step-by-step manual link with correct URL in HTML tab", () => {
render(<OnboardingSetupInstructions {...defaultProps} />);
const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i });
expect(manualLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/app-surveys/framework-guides#html"
);
});
});
@@ -34,10 +34,9 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys --> const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var apiHost = "${webAppUrl}"; var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}"; var environmentId = "${environmentId}";
var userId = "testUser"; var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost, userId: userId})},500)}();
</script> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
@@ -45,9 +44,9 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys --> const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var apiHost = "${webAppUrl}"; var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}"; var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost})},500)}(); var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
@@ -56,10 +55,9 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.init({ formbricks.setup({
environmentId: "${environmentId}", environmentId: "${environmentId}",
apiHost: "${webAppUrl}", appUrl: "${webAppUrl}",
userId: "testUser",
}); });
} }
@@ -75,9 +73,9 @@ export const OnboardingSetupInstructions = ({
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.init({ formbricks.setup({
environmentId: "${environmentId}", environmentId: "${environmentId}",
apiHost: "${webAppUrl}", appUrl: "${webAppUrl}",
}); });
} }
@@ -1,13 +1,10 @@
import { getDefaultEndingCard } from "@/app/lib/templates"; import { getDefaultEndingCard } from "@/app/lib/templates";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { TFnType } from "@tolgee/react"; import { TFnType } from "@tolgee/react";
import { logger } from "@formbricks/logger";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
function logError(error: Error, context: string) {
console.error(`Error in ${context}:`, error);
}
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => { export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
try { try {
return { return {
@@ -19,7 +16,7 @@ export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
}, },
}; };
} catch (error) { } catch (error) {
logError(error, "getXMSurveyDefault"); logger.error(error, "Failed to create default XM survey template");
throw error; // Re-throw after logging throw error; // Re-throw after logging
} }
}; };
@@ -449,7 +446,7 @@ export const getXMTemplates = (t: TFnType): TXMTemplate[] => {
enpsSurvey(t), enpsSurvey(t),
]; ];
} catch (error) { } catch (error) {
logError(error, "getXMTemplates"); logger.error(error, "Unable to load XM templates, returning empty array");
return []; // Return an empty array or handle as needed return []; // Return an empty array or handle as needed
} }
}; };
@@ -1,27 +1,25 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service"; import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session || !session.user) { const { session, organization } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) return notFound(); if (!user) return notFound();
const organization = await getOrganization(params.organizationId);
if (!organization) return notFound();
const organizations = await getOrganizationsByUserId(session.user.id); const organizations = await getOrganizationsByUserId(session.user.id);
const { features } = await getEnterpriseLicense(); const { features } = await getEnterpriseLicense();
@@ -0,0 +1,156 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import ProjectOnboardingLayout from "./layout";
// Mock all the modules and functions that this layout uses:
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/auth", () => ({
canUserAccessOrganization: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
// Return a mock translator that just returns the key
return (key: string) => key;
}),
}));
// mock the child components
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
describe("ProjectOnboardingLayout", () => {
beforeEach(() => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const layoutElement = await ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
// Layout returns nothing after redirect
expect(layoutElement).toBeUndefined();
});
it("throws an error if user does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce(null); // no user in DB
await expect(
ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user cannot access organization", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
await expect(
ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Child</div>,
})
).rejects.toThrow("common.not_authorized");
});
it("throws an error if organization does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
vi.mocked(getOrganization).mockResolvedValueOnce(null);
await expect(
ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
// Provide valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
vi.mocked(getOrganization).mockResolvedValueOnce({
id: "org-123",
name: "Test Org",
billing: {
plan: "enterprise",
},
} as TOrganization);
let layoutElement: React.ReactNode;
// Because it's an async server component, do it in an act
await act(async () => {
layoutElement = await ProjectOnboardingLayout({
params: { organizationId: "org-123" },
children: <div data-testid="child-content">Hello!</div>,
});
render(layoutElement);
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
});
});
@@ -4,6 +4,7 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service"; import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
@@ -16,7 +17,8 @@ const ProjectOnboardingLayout = async (props) => {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -26,8 +28,9 @@ const ProjectOnboardingLayout = async (props) => {
} }
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
if (!isAuthorized) { if (!isAuthorized) {
throw AuthorizationError; throw new AuthorizationError(t("common.not_authorized"));
} }
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
@@ -43,6 +46,7 @@ const ProjectOnboardingLayout = async (props) => {
organizationId={organization.id} organizationId={organization.id}
organizationName={organization.name} organizationName={organization.name}
organizationBilling={organization.billing} organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/> />
<ToasterClient /> <ToasterClient />
{children} {children}
@@ -1,10 +1,9 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react"; import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service"; import { getUserProjects } from "@formbricks/lib/project/service";
@@ -17,8 +16,10 @@ interface ChannelPageProps {
const Page = async (props: ChannelPageProps) => { const Page = async (props: ChannelPageProps) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions);
if (!session || !session.user) { const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -1,10 +1,9 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react"; import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getUserProjects } from "@formbricks/lib/project/service"; import { getUserProjects } from "@formbricks/lib/project/service";
@@ -17,8 +16,10 @@ interface ModePageProps {
const Page = async (props: ModePageProps) => { const Page = async (props: ModePageProps) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions);
if (!session || !session.user) { const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -231,6 +231,7 @@ export const ProjectSettings = ({
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4"> <div className="z-0 h-3/4 w-3/4">
<SurveyInline <SurveyInline
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)} survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }} styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false} isBrandingEnabled={false}
@@ -1,16 +1,14 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants"; import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service"; import { getUserProjects } from "@formbricks/lib/project/service";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
@@ -29,25 +27,20 @@ const Page = async (props: ProjectSettingsPageProps) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session || !session.user) { const { session, organization } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const channel = searchParams.channel || null; const channel = searchParams.channel ?? null;
const industry = searchParams.industry || null; const industry = searchParams.industry ?? null;
const mode = searchParams.mode || "surveys"; const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!organizationTeams) { if (!organizationTeams) {
@@ -0,0 +1,191 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout";
// mock all dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => key; // trivial translator returning the key
}),
}));
// mock child components rendered by the layout:
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
<div data-testid="dev-environment-banner">{environment?.id || "no-env"}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
describe("SurveyEditorEnvironmentLayout", () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
});
it("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
// No JSX is returned after redirect
expect(layoutElement).toBeUndefined();
});
it("throws error if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no environment is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
})
).rejects.toThrow("common.environment_not_found");
});
it("renders environment layout if everything is valid", async () => {
// Provide all valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env-123",
name: "My Test Environment",
} as unknown as TEnvironment);
// Because it's an async server component, we typically wrap in act(...)
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
});
render(layoutElement);
});
// Now confirm we got the child plus all the mocked sub-components
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
});
});
@@ -7,6 +7,7 @@ import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service"; import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -20,7 +21,8 @@ const SurveyEditorEnvironmentLayout = async (props) => {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -46,24 +48,23 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
return ( return (
<> <ResponseFilterProvider>
<ResponseFilterProvider> <PosthogIdentify
<PosthogIdentify session={session}
session={session} user={user}
user={user} environmentId={params.environmentId}
environmentId={params.environmentId} organizationId={organization.id}
organizationId={organization.id} organizationName={organization.name}
organizationName={organization.name} organizationBilling={organization.billing}
organizationBilling={organization.billing} isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/> />
<FormbricksClient userId={user.id} email={user.email} /> <FormbricksClient userId={user.id} email={user.email} />
<ToasterClient /> <ToasterClient />
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} /> <DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div> <div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div> </div>
</ResponseFilterProvider> </ResponseFilterProvider>
</>
); );
}; };
@@ -0,0 +1,77 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
// Mock next/navigation hooks.
vi.mock("next/navigation", () => ({
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the environment variables.
vi.mock("@formbricks/lib/env", () => ({
env: {
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
},
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
}));
// Mock the Formbricks SDK module.
vi.mock("@formbricks/js", () => ({
__esModule: true,
default: {
setup: vi.fn(),
setUserId: vi.fn(),
setEmail: vi.fn(),
registerRouteChange: vi.fn(),
},
}));
describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="user-123" email="test@example.com" />);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
environmentId: "env-test",
appUrl: "https://api.test.com",
});
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
// And the second effect should always register the route change when Formbricks is enabled.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="" email="test@example.com" />);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();
expect(mockSetUserId).not.toHaveBeenCalled();
expect(mockSetEmail).not.toHaveBeenCalled();
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
});
@@ -12,12 +12,12 @@ export const FormbricksClient = ({ userId, email }: { userId: string; email: str
useEffect(() => { useEffect(() => {
if (formbricksEnabled && userId) { if (formbricksEnabled && userId) {
formbricks.init({ formbricks.setup({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "", environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "", appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId,
}); });
formbricks.setUserId(userId);
formbricks.setEmail(email); formbricks.setEmail(email);
} }
}, [userId, email]); }, [userId, email]);
@@ -2,21 +2,14 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData"; import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading"; import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal"; import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next"; import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getActionClasses } from "@formbricks/lib/actionClass/service"; import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironments } from "@formbricks/lib/environment/service"; import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -25,51 +18,24 @@ export const metadata: Metadata = {
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions);
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate(); const t = await getTranslate();
const [actionClasses, organization, project] = await Promise.all([
getActionClasses(params.environmentId), const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]);
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new Error(t("common.project_not_found"));
}
const environments = await getEnvironments(project.id); const environments = await getEnvironments(project.id);
const currentEnvironment = environments.find((env) => env.id === params.environmentId);
if (!currentEnvironment) {
throw new Error(t("common.environment_not_found"));
}
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0]; const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id); const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, project.id);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
if (isBilling) { if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`); return redirect(`/environments/${params.environmentId}/settings/billing`);
} }
const isReadOnly = isMember && hasReadAccess;
const renderAddActionButton = () => ( const renderAddActionButton = () => (
<AddActionModal <AddActionModal
environmentId={params.environmentId} environmentId={params.environmentId}
@@ -82,7 +48,7 @@ const Page = async (props) => {
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} /> <PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
<ActionClassesTable <ActionClassesTable
environment={currentEnvironment} environment={environment}
otherEnvironment={otherEnvironment} otherEnvironment={otherEnvironment}
otherEnvActionClasses={otherEnvActionClasses} otherEnvActionClasses={otherEnvActionClasses}
environmentId={params.environmentId} environmentId={params.environmentId}
@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
organizationProjectsLimit={organizationProjectsLimit} organizationProjectsLimit={organizationProjectsLimit}
user={user} user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole} membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active} isLicenseActive={active}
@@ -63,6 +63,7 @@ interface NavigationProps {
projects: TProject[]; projects: TProject[];
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
organizationProjectsLimit: number; organizationProjectsLimit: number;
isLicenseActive: boolean; isLicenseActive: boolean;
@@ -79,6 +80,7 @@ export const MainNavigation = ({
isFormbricksCloud, isFormbricksCloud,
organizationProjectsLimit, organizationProjectsLimit,
isLicenseActive, isLicenseActive,
isDevelopment,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -296,7 +298,7 @@ export const MainNavigation = ({
<div> <div>
{/* New Version Available */} {/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && ( {!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link <Link
href="https://github.com/formbricks/formbricks/releases" href="https://github.com/formbricks/formbricks/releases"
target="_blank" target="_blank"
@@ -0,0 +1,151 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { PosthogIdentify } from "./PosthogIdentify";
type PartialPostHog = Partial<ReturnType<typeof usePostHog>>;
vi.mock("posthog-js/react", () => ({
usePostHog: vi.fn(),
}));
describe("PosthogIdentify", () => {
beforeEach(() => {
cleanup();
});
it("identifies the user and sets groups when isPosthogEnabled is true", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={
{
name: "Test User",
email: "test@example.com",
role: "engineer",
objective: "increase_conversion",
} as TUser
}
environmentId="env-456"
organizationId="org-789"
organizationName="Test Org"
organizationBilling={
{
plan: "enterprise",
limits: { monthly: { responses: 1000, miu: 5000 }, projects: 10 },
} as TOrganizationBilling
}
isPosthogEnabled
/>
);
// verify that identify is called with the session user id + extra info
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
name: "Test User",
email: "test@example.com",
role: "engineer",
objective: "increase_conversion",
});
// environment + organization groups
expect(mockGroup).toHaveBeenCalledTimes(2);
expect(mockGroup).toHaveBeenCalledWith("environment", "env-456", { name: "env-456" });
expect(mockGroup).toHaveBeenCalledWith("organization", "org-789", {
name: "Test Org",
plan: "enterprise",
responseLimit: 1000,
miuLimit: 5000,
});
});
it("does nothing if isPosthogEnabled is false", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled={false}
/>
);
expect(mockIdentify).not.toHaveBeenCalled();
expect(mockGroup).not.toHaveBeenCalled();
});
it("does nothing if session user is missing", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
// no user in session
session={{} as any}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled
/>
);
// Because there's no session.user, we skip identify
expect(mockIdentify).not.toHaveBeenCalled();
expect(mockGroup).not.toHaveBeenCalled();
});
it("identifies user but does not group if environmentId/organizationId not provided", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
const mockPostHog: PartialPostHog = {
identify: mockIdentify,
group: mockGroup,
};
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
render(
<PosthogIdentify
session={{ user: { id: "user-123" } } as Session}
user={{ name: "Test User", email: "test@example.com" } as TUser}
isPosthogEnabled
/>
);
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
name: "Test User",
email: "test@example.com",
role: undefined,
objective: undefined,
});
// No environmentId or organizationId => no group calls
expect(mockGroup).not.toHaveBeenCalled();
});
});
@@ -3,12 +3,9 @@
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import { useEffect } from "react"; import { useEffect } from "react";
import { env } from "@formbricks/lib/env";
import { TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
interface PosthogIdentifyProps { interface PosthogIdentifyProps {
session: Session; session: Session;
user: TUser; user: TUser;
@@ -16,6 +13,7 @@ interface PosthogIdentifyProps {
organizationId?: string; organizationId?: string;
organizationName?: string; organizationName?: string;
organizationBilling?: TOrganizationBilling; organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
} }
export const PosthogIdentify = ({ export const PosthogIdentify = ({
@@ -25,11 +23,12 @@ export const PosthogIdentify = ({
organizationId, organizationId,
organizationName, organizationName,
organizationBilling, organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => { }: PosthogIdentifyProps) => {
const posthog = usePostHog(); const posthog = usePostHog();
useEffect(() => { useEffect(() => {
if (posthogEnabled && session.user && posthog) { if (isPosthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, { posthog.identify(session.user.id, {
name: user.name, name: user.name,
email: user.email, email: user.email,
@@ -59,6 +58,7 @@ export const PosthogIdentify = ({
user.email, user.email,
user.role, user.role,
user.objective, user.objective,
isPosthogEnabled,
]); ]);
return null; return null;
@@ -6,7 +6,7 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getTodayDate } from "@/app/lib/surveys/surveys"; import { getTodayDate } from "@/app/lib/surveys/surveys";
import { createContext, useCallback, useContext, useState } from "react"; import React, { createContext, useCallback, useContext, useState } from "react";
export interface FilterValue { export interface FilterValue {
questionType: Partial<QuestionOption>; questionType: Partial<QuestionOption>;
@@ -1,6 +1,5 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons"; import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -24,7 +23,6 @@ export const TopControlBar = ({
<TopControlButtons <TopControlButtons
environment={environment} environment={environment}
environments={environments} environments={environments}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={membershipRole} membershipRole={membershipRole}
projectPermission={projectPermission} projectPermission={projectPermission}
/> />
@@ -6,9 +6,9 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react"; import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import formbricks from "@formbricks/js";
import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
@@ -16,7 +16,6 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
interface TopControlButtonsProps { interface TopControlButtonsProps {
environment: TEnvironment; environment: TEnvironment;
environments: TEnvironment[]; environments: TEnvironment[];
isFormbricksCloud: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null; projectPermission: TTeamPermission | null;
} }
@@ -24,7 +23,6 @@ interface TopControlButtonsProps {
export const TopControlButtons = ({ export const TopControlButtons = ({
environment, environment,
environments, environments,
isFormbricksCloud,
membershipRole, membershipRole,
projectPermission, projectPermission,
}: TopControlButtonsProps) => { }: TopControlButtonsProps) => {
@@ -38,19 +36,15 @@ export const TopControlButtons = ({
return ( return (
<div className="z-50 flex items-center space-x-2"> <div className="z-50 flex items-center space-x-2">
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />} {!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
{isFormbricksCloud && (
<TooltipRenderer tooltipContent={t("common.share_feedback")}> <TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button <Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
variant="ghost" <Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
size="icon" <BugIcon />
className="h-fit w-fit bg-slate-50 p-1" </Link>
onClick={() => { </Button>
formbricks.track("Top Menu: Product Feedback"); </TooltipRenderer>
}}>
<MessageCircleQuestionIcon />
</Button>
</TooltipRenderer>
)}
<TooltipRenderer tooltipContent={t("common.account")}> <TooltipRenderer tooltipContent={t("common.account")}>
<Button <Button
variant="ghost" variant="ghost"
@@ -1,3 +1,4 @@
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
export const fetchTables = async (environmentId: string, baseId: string) => { export const fetchTables = async (environmentId: string, baseId: string) => {
@@ -17,7 +18,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
console.error(res.text); const errorText = await res.text();
logger.error({ errorText }, "authorize: Could not fetch airtable config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,21 +1,14 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"; import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getAirtableTables } from "@formbricks/lib/airtable/service"; import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service"; import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -24,48 +17,25 @@ const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID; const isEnabled = !!AIRTABLE_CLIENT_ID;
const [session, surveys, integrations, environment] = await Promise.all([
getServerSession(authOptions), const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find( const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
(integration): integration is TIntegrationAirtable => integration.type === "airtable" (integration): integration is TIntegrationAirtable => integration.type === "airtable"
); );
let airtableArray: TIntegrationItem[] = []; let airtableArray: TIntegrationItem[] = [];
if (airtableIntegration && airtableIntegration.config.key) { if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId); airtableArray = await getAirtableTables(params.environmentId);
} }
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -1,25 +1,41 @@
"use server"; "use server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { getServerSession } from "next-auth"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service"; import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service";
import { AuthorizationError } from "@formbricks/types/errors"; import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
export async function getSpreadsheetNameByIdAction( const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: TIntegrationGoogleSheets, googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: string, environmentId: z.string(),
spreadsheetId: string spreadsheetId: z.string(),
) { });
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); export const getSpreadsheetNameByIdAction = authenticatedActionClient
if (!isAuthorized) throw new AuthorizationError("Not authorized"); .schema(ZGetSpreadsheetNameByIdAction)
const integrationData = structuredClone(googleSheetIntegration); .action(async ({ ctx, parsedInput }) => {
integrationData.config.data.forEach((data) => { await checkAuthorizationUpdated({
data.createdAt = new Date(data.createdAt); userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integrationData = structuredClone(parsedInput.googleSheetIntegration);
integrationData.config.data.forEach((data) => {
data.createdAt = new Date(data.createdAt);
});
return await getSpreadsheetNameById(integrationData, parsedInput.spreadsheetId);
}); });
return await getSpreadsheetNameById(integrationData, spreadsheetId);
}
@@ -8,6 +8,7 @@ import {
isValidGoogleSheetsUrl, isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util"; } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png"; import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -115,11 +116,18 @@ export const AddIntegrationModal = ({
throw new Error(t("environments.integrations.select_at_least_one_question_error")); throw new Error(t("environments.integrations.select_at_least_one_question_error"));
} }
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl); const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetName = await getSpreadsheetNameByIdAction( const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration, googleSheetIntegration,
environmentId, environmentId,
spreadsheetId spreadsheetId,
); });
if (!spreadsheetNameResponse?.data) {
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true); setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId; integrationData.spreadsheetId = spreadsheetId;
@@ -1,3 +1,5 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => { export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/google-sheet`, { const res = await fetch(`${apiHost}/api/google-sheet`, {
method: "GET", method: "GET",
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
console.error(res.text); const errorText = await res.text();
logger.error({ errorText }, "authorize: Could not fetch google sheet config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,13 +1,10 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper"; import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_ID,
@@ -15,11 +12,7 @@ import {
GOOGLE_SHEETS_REDIRECT_URL, GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service"; import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
@@ -27,43 +20,20 @@ const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL); const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const [session, surveys, integrations, environment] = await Promise.all([
getServerSession(authOptions), const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find( const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets" (integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
); );
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -7,6 +7,7 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service"; import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils"; import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate"; import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
@@ -34,7 +35,7 @@ export const getSurveys = reactCache(
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma)); return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error); logger.error({ error }, "getSurveys: Could not fetch surveys");
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
} }
throw error; throw error;
@@ -1,3 +1,5 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => { export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/notion`, { const res = await fetch(`${apiHost}/api/v1/integrations/notion`, {
method: "GET", method: "GET",
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
console.error(res.text); const errorText = await res.text();
logger.error({ errorText }, "authorize: Could not fetch notion config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,13 +1,10 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper"; import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
NOTION_AUTH_URL, NOTION_AUTH_URL,
@@ -16,12 +13,8 @@ import {
NOTION_REDIRECT_URI, NOTION_REDIRECT_URI,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service"; import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getNotionDatabases } from "@formbricks/lib/notion/service"; import { getNotionDatabases } from "@formbricks/lib/notion/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
@@ -34,44 +27,20 @@ const Page = async (props) => {
NOTION_AUTH_URL && NOTION_AUTH_URL &&
NOTION_REDIRECT_URI NOTION_REDIRECT_URI
); );
const [session, surveys, notionIntegration, environment] = await Promise.all([
getServerSession(authOptions), const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"), getIntegrationByType(params.environmentId, "notion"),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
let databasesArray: TIntegrationNotionDatabase[] = []; let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) { if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? []; databasesArray = (await getNotionDatabases(environment.id)) ?? [];
} }
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -9,71 +9,40 @@ import notionLogo from "@/images/notion.png";
import SlackLogo from "@/images/slacklogo.png"; import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png"; import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png"; import ZapierLogo from "@/images/zapier-small.png";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Card } from "@/modules/ui/components/integration-card"; import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import Image from "next/image"; import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service"; import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { TIntegrationType } from "@formbricks/types/integration"; import { TIntegrationType } from "@formbricks/types/integration";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const environmentId = params.environmentId;
const t = await getTranslate(); const t = await getTranslate();
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
const [ const [
environment,
integrations, integrations,
organization,
session,
userWebhookCount, userWebhookCount,
zapierWebhookCount, zapierWebhookCount,
makeWebhookCount, makeWebhookCount,
n8nwebhookCount, n8nwebhookCount,
activePiecesWebhookCount, activePiecesWebhookCount,
] = await Promise.all([ ] = await Promise.all([
getEnvironment(environmentId), getIntegrations(params.environmentId),
getIntegrations(environmentId), getWebhookCountBySource(params.environmentId, "user"),
getOrganizationByEnvironmentId(params.environmentId), getWebhookCountBySource(params.environmentId, "zapier"),
getServerSession(authOptions), getWebhookCountBySource(params.environmentId, "make"),
getWebhookCountBySource(environmentId, "user"), getWebhookCountBySource(params.environmentId, "n8n"),
getWebhookCountBySource(environmentId, "zapier"), getWebhookCountBySource(params.environmentId, "activepieces"),
getWebhookCountBySource(environmentId, "make"),
getWebhookCountBySource(environmentId, "n8n"),
getWebhookCountBySource(environmentId, "activepieces"),
]); ]);
const isIntegrationConnected = (type: TIntegrationType) => const isIntegrationConnected = (type: TIntegrationType) =>
integrations.some((integration) => integration.type === type); integrations.some((integration) => integration.type === type);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isBilling) { if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`); return redirect(`/environments/${params.environmentId}/settings/billing`);
@@ -244,7 +213,7 @@ const Page = async (props) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart", docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"), docsText: t("common.docs"),
docsNewTab: true, docsNewTab: true,
connectHref: `/environments/${environmentId}/project/app-connection`, connectHref: `/environments/${params.environmentId}/project/app-connection`,
connectText: t("common.connect"), connectText: t("common.connect"),
connectNewTab: false, connectNewTab: false,
label: "Javascript SDK", label: "Javascript SDK",
@@ -1,3 +1,5 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => { export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/slack`, { const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
method: "GET", method: "GET",
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
}); });
if (!res.ok) { if (!res.ok) {
console.error(res.text); const errorText = await res.text();
logger.error({ errorText }, "authorize: Could not fetch slack config");
throw new Error("Could not create response"); throw new Error("Could not create response");
} }
const resJSON = await res.json(); const resJSON = await res.json();
@@ -1,20 +1,13 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper"; import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service"; import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TIntegrationSlack } from "@formbricks/types/integration/slack";
@@ -23,40 +16,16 @@ const Page = async (props) => {
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET); const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
const t = await getTranslate(); const t = await getTranslate();
const [session, surveys, slackIntegration, environment] = await Promise.all([
getServerSession(authOptions), const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"), getIntegrationByType(params.environmentId, "slack"),
getEnvironment(params.environmentId),
]); ]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) { if (isReadOnly) {
redirect("./"); redirect("./");
} }
@@ -0,0 +1,250 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout";
// mock all the dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => {
return key;
};
}),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/aiModels", () => ({
llmModel: {},
}));
// mock all the components that are rendered in the layout
vi.mock("./components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: () => <div data-testid="mock-storage-handler" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-environment-result">{children}</div>
),
}));
describe("EnvLayout", () => {
beforeEach(() => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
// Since it's an async server component, call EnvLayout yourself:
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
});
// Because we have no session, we expect a redirect to "/auth/login"
expect(redirect).toHaveBeenCalledWith("/auth/login");
// If your code calls redirect() early and returns no JSX,
// layoutElement might be undefined or null.
expect(layoutElement).toBeUndefined();
});
it("redirects to /auth/login if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(layoutElement).toBeUndefined();
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no project is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow("project_not_found");
});
it("calls notFound if membership is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow("membership_not_found");
});
it("renders environment layout if everything is valid", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "membership-123",
} as unknown as TMembership);
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
});
// Now render the fully resolved layout
render(layoutElement);
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
});
});
@@ -4,7 +4,8 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -25,7 +26,8 @@ const EnvLayout = async (props: {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) {
if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
@@ -49,27 +51,29 @@ const EnvLayout = async (props: {
} }
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) return notFound();
if (!membership) {
throw new Error(t("common.membership_not_found"));
}
return ( return (
<> <ResponseFilterProvider>
<ResponseFilterProvider> <PosthogIdentify
<PosthogIdentify session={session}
session={session} user={user}
user={user} environmentId={params.environmentId}
environmentId={params.environmentId} organizationId={organization.id}
organizationId={organization.id} organizationName={organization.name}
organizationName={organization.name} organizationBilling={organization.billing}
organizationBilling={organization.billing} isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/> />
<FormbricksClient userId={user.id} email={user.email} /> <FormbricksClient userId={user.id} email={user.email} />
<ToasterClient /> <ToasterClient />
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}> <EnvironmentLayout environmentId={params.environmentId} session={session}>
{children} {children}
</EnvironmentLayout> </EnvironmentLayout>
</ResponseFilterProvider> </ResponseFilterProvider>
</>
); );
}; };
@@ -1,24 +1,11 @@
import { authOptions } from "@/modules/auth/lib/authOptions"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
const EnvironmentPage = async (props) => { const EnvironmentPage = async (props) => {
const params = await props.params; const params = await props.params;
const session = await getServerSession(authOptions); const { session, organization } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role); const { isBilling } = getAccessFlags(currentUserMembership?.role);
@@ -1,3 +0,0 @@
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
export default APIKeysLoading;
@@ -1,3 +0,0 @@
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
export default APIKeysPage;
@@ -157,6 +157,10 @@ const Page = async (props) => {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
if (!memberships) {
throw new Error(t("common.membership_not_found"));
}
if (user?.notificationSettings) { if (user?.notificationSettings) {
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships); user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
} }
@@ -1,18 +1,14 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id"; import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
getOrganizationByEnvironmentId,
getOrganizationsWhereUserIsSingleOwner,
} from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount"; import { DeleteAccount } from "./components/DeleteAccount";
@@ -25,20 +21,16 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const { environmentId } = params; const { environmentId } = params;
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const organization = await getOrganizationByEnvironmentId(environmentId); const { session } = await getEnvironmentAuth(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id); const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
const user = session && session.user ? await getUser(session.user.id) : null; const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new Error(t("common.user_not_found"));
}
return ( return (
<PageContentWrapper> <PageContentWrapper>
@@ -0,0 +1,6 @@
import Loading from "@/modules/organization/settings/api-keys/loading";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
export default function LoadingPage() {
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
@@ -0,0 +1,3 @@
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
@@ -54,6 +54,12 @@ export const OrganizationSettingsNavbar = ({
hidden: isFormbricksCloud || isPricingDisabled, hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"), current: pathname?.includes("/enterprise"),
}, },
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
},
]; ];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />; return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -1,18 +1,14 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
@@ -21,20 +17,8 @@ const Page = async (props) => {
notFound(); notFound();
} }
const session = await getServerSession(authOptions); const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const isPricingDisabled = isMember; const isPricingDisabled = isMember;
if (isPricingDisabled) { if (isPricingDisabled) {
@@ -3,15 +3,11 @@ import {
getIsOrganizationAIReady, getIsOrganizationAIReady,
getWhiteLabelPermission, getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import Page from "./page"; import Page from "./page";
@@ -37,6 +33,12 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url", WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
})); }));
vi.mock("next-auth", () => ({ vi.mock("next-auth", () => ({
@@ -51,16 +53,8 @@ vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(), getUser: vi.fn(),
})); }));
vi.mock("@formbricks/lib/organization/service", () => ({ vi.mock("@/modules/environments/lib/utils", () => ({
getOrganizationByEnvironmentId: vi.fn(), getEnvironmentAuth: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
@@ -70,26 +64,21 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
})); }));
describe("Page", () => { describe("Page", () => {
const mockParams = { environmentId: "test-environment-id" }; let mockEnvironmentAuth = {
const mockSession = { user: { id: "test-user-id" } }; session: { user: { id: "test-user-id" } },
currentUserMembership: { role: "owner" },
organization: { id: "test-organization-id", billing: { plan: "free" } },
isOwner: true,
isManager: false,
} as unknown as TEnvironmentAuth;
const mockUser = { id: "test-user-id" } as TUser; const mockUser = { id: "test-user-id" } as TUser;
const mockOrganization = { id: "test-organization-id", billing: { plan: "free" } } as TOrganization;
const mockMembership = { role: "owner" } as TMembership;
const mockTranslate = vi.fn((key) => key); const mockTranslate = vi.fn((key) => key);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate); vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: true,
isManager: false,
isBilling: false,
isMember: false,
});
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true); vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true); vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
@@ -105,8 +94,10 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("renders if session user id is null", async () => { it("renders if session user id empty", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: null } }); mockEnvironmentAuth.session.user.id = "";
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
const props = { const props = {
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env-123" }),
@@ -117,17 +108,13 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("throws an error if the session is not found", async () => { it("handles getEnvironmentAuth error", async () => {
vi.mocked(getServerSession).mockResolvedValue(null); vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error"));
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow("common.session_not_found"); const props = {
}); params: Promise.resolve({ environmentId: "env-123" }),
};
it("throws an error if the organization is not found", async () => { await expect(Page(props)).rejects.toThrow("Authentication error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow(
"common.organization_not_found"
);
}); });
}); });
@@ -1,22 +1,17 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle"; import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { import {
getIsMultiOrgEnabled, getIsMultiOrgEnabled,
getIsOrganizationAIReady, getIsOrganizationAIReady,
getWhiteLabelPermission, getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings"; import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id"; import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import React from "react";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -25,20 +20,13 @@ import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) { const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
throw new Error(t("common.session_not_found")); params.environmentId
} );
const user = session?.user?.id ? await getUser(session.user.id) : null; const user = session?.user?.id ? await getUser(session.user.id) : null;
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
@@ -100,7 +88,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<SettingsId title={t("common.organization")} id={organization.id}></SettingsId> <SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -2,7 +2,6 @@
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import React from "react";
import { cn } from "@formbricks/lib/cn"; import { cn } from "@formbricks/lib/cn";
export const SettingsCard = ({ export const SettingsCard = ({
@@ -3,24 +3,17 @@ import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner"; import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { import {
MAX_RESPONSES_FOR_INSIGHT_GENERATION, MAX_RESPONSES_FOR_INSIGHT_GENERATION,
RESPONSES_PER_PAGE, RESPONSES_PER_PAGE,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service"; import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
@@ -30,53 +23,32 @@ import { findMatchingLocale } from "@formbricks/lib/utils/locale";
const Page = async (props) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const [survey, environment] = await Promise.all([
getSurvey(params.surveyId),
getEnvironment(params.environmentId),
]);
if (!environment) { const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
throw new Error(t("common.environment_not_found"));
} const survey = await getSurvey(params.surveyId);
if (!survey) { if (!survey) {
throw new Error(t("common.survey_not_found")); throw new Error(t("common.survey_not_found"));
} }
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const tags = await getTagsByEnvironmentId(params.environmentId); const tags = await getTagsByEnvironmentId(params.environmentId);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId); const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const permission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasReadAccess } = getTeamPermissionFlags(permission);
const isReadOnly = isMember && hasReadAccess;
const isAIEnabled = await getIsAIEnabled({ const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled, isAIEnabled: organization.isAIEnabled,
billing: organization.billing, billing: organization.billing,
}); });
const shouldGenerateInsights = needsInsightsGeneration(survey); const shouldGenerateInsights = needsInsightsGeneration(survey);
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
return ( return (
<PageContentWrapper> <PageContentWrapper>
@@ -87,8 +59,8 @@ const Page = async (props) => {
environment={environment} environment={environment}
survey={survey} survey={survey}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
webAppUrl={WEBAPP_URL}
user={user} user={user}
surveyDomain={surveyDomain}
/> />
}> }>
{isAIEnabled && shouldGenerateInsights && ( {isAIEnabled && shouldGenerateInsights && (
@@ -23,19 +23,19 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps { interface ShareEmbedSurveyProps {
survey: TSurvey; survey: TSurvey;
surveyDomain: string;
open: boolean; open: boolean;
modalView: "start" | "embed" | "panel"; modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
webAppUrl: string;
user: TUser; user: TUser;
} }
export const ShareEmbedSurvey = ({ export const ShareEmbedSurvey = ({
survey, survey,
surveyDomain,
open, open,
modalView, modalView,
setOpen, setOpen,
webAppUrl,
user, user,
}: ShareEmbedSurveyProps) => { }: ShareEmbedSurveyProps) => {
const router = useRouter(); const router = useRouter();
@@ -60,7 +60,7 @@ export const ShareEmbedSurvey = ({
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id); const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start"); const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState(webAppUrl + "/s/" + survey.id); const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => { useEffect(() => {
if (survey.type !== "link") { if (survey.type !== "link") {
@@ -104,8 +104,8 @@ export const ShareEmbedSurvey = ({
<DialogDescription className="hidden" /> <DialogDescription className="hidden" />
<ShareSurveyLink <ShareSurveyLink
survey={survey} survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={user.locale} locale={user.locale}
/> />
@@ -159,8 +159,8 @@ export const ShareEmbedSurvey = ({
survey={survey} survey={survey}
email={email} email={email}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
webAppUrl={webAppUrl}
locale={user.locale} locale={user.locale}
/> />
) : showView === "panel" ? ( ) : showView === "panel" ? (
@@ -3,6 +3,8 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey"; import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage"; import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { IconBar } from "@/modules/ui/components/iconbar"; import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -18,8 +20,8 @@ interface SurveyAnalysisCTAProps {
survey: TSurvey; survey: TSurvey;
environment: TEnvironment; environment: TEnvironment;
isReadOnly: boolean; isReadOnly: boolean;
webAppUrl: string;
user: TUser; user: TUser;
surveyDomain: string;
} }
interface ModalState { interface ModalState {
@@ -33,8 +35,8 @@ export const SurveyAnalysisCTA = ({
survey, survey,
environment, environment,
isReadOnly, isReadOnly,
webAppUrl,
user, user,
surveyDomain,
}: SurveyAnalysisCTAProps) => { }: SurveyAnalysisCTAProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -48,7 +50,8 @@ export const SurveyAnalysisCTA = ({
dropdown: false, dropdown: false,
}); });
const surveyUrl = useMemo(() => `${webAppUrl}/s/${survey.id}`, [survey.id, webAppUrl]); const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -71,8 +74,11 @@ export const SurveyAnalysisCTA = ({
}; };
const handleCopyLink = () => { const handleCopyLink = () => {
navigator.clipboard refreshSingleUseId()
.writeText(surveyUrl) .then((newId) => {
const linkToCopy = copySurveyLink(surveyUrl, newId);
return navigator.clipboard.writeText(linkToCopy);
})
.then(() => { .then(() => {
toast.success(t("common.copied_to_clipboard")); toast.success(t("common.copied_to_clipboard"));
}) })
@@ -166,9 +172,9 @@ export const SurveyAnalysisCTA = ({
<ShareEmbedSurvey <ShareEmbedSurvey
key={key} key={key}
survey={survey} survey={survey}
surveyDomain={surveyDomain}
open={modalState[key as keyof ModalState]} open={modalState[key as keyof ModalState]}
setOpen={setOpen} setOpen={setOpen}
webAppUrl={webAppUrl}
user={user} user={user}
modalView={modalView} modalView={modalView}
/> />
@@ -20,8 +20,8 @@ interface EmbedViewProps {
survey: any; survey: any;
email: string; email: string;
surveyUrl: string; surveyUrl: string;
surveyDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>; setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
webAppUrl: string;
locale: TUserLocale; locale: TUserLocale;
} }
@@ -35,8 +35,8 @@ export const EmbedView = ({
survey, survey,
email, email,
surveyUrl, surveyUrl,
surveyDomain,
setSurveyUrl, setSurveyUrl,
webAppUrl,
locale, locale,
}: EmbedViewProps) => { }: EmbedViewProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
@@ -82,8 +82,8 @@ export const EmbedView = ({
) : activeId === "link" ? ( ) : activeId === "link" ? (
<LinkTab <LinkTab
survey={survey} survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={locale} locale={locale}
/> />
@@ -8,14 +8,15 @@ import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps { interface LinkTabProps {
survey: TSurvey; survey: TSurvey;
webAppUrl: string;
surveyUrl: string; surveyUrl: string;
surveyDomain: string;
setSurveyUrl: (url: string) => void; setSurveyUrl: (url: string) => void;
locale: TUserLocale; locale: TUserLocale;
} }
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => { export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const docsLinks = [ const docsLinks = [
{ {
title: t("environments.surveys.summary.data_prefilling"), title: t("environments.surveys.summary.data_prefilling"),
@@ -42,12 +43,13 @@ export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }:
</p> </p>
<ShareSurveyLink <ShareSurveyLink
survey={survey} survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={locale} locale={locale}
/> />
</div> </div>
<div className="flex flex-wrap justify-between gap-2"> <div className="flex flex-wrap justify-between gap-2">
<p className="pt-2 font-semibold text-slate-700"> <p className="pt-2 font-semibold text-slate-700">
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡 {t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
@@ -0,0 +1,132 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "../SurveyAnalysisCTA";
// Mock constants
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
ENCRYPTION_KEY: "test",
ENTERPRISE_LICENSE_KEY: "test",
GITHUB_ID: "test",
GITHUB_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
}));
// Create a spy for refreshSingleUseId so we can override it in tests
const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
// Mock useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: () => ({
refreshSingleUseId: refreshSingleUseIdSpy,
}),
}));
const mockSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => mockSearchParams, // Reuse the same object
usePathname: () => "/current",
}));
// Mock copySurveyLink to return a predictable string
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");
// Set up a fake clipboard
const writeTextMock = vi.fn(() => Promise.resolve());
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
});
const dummySurvey = {
id: "survey123",
type: "link",
environmentId: "env123",
status: "active",
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
const surveyDomain = "https://surveys.test.formbricks.com";
describe("SurveyAnalysisCTA - handleCopyLink", () => {
afterEach(() => {
cleanup();
});
it("calls copySurveyLink and clipboard.writeText on success", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith(
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
it("shows error toast on failure", async () => {
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link");
});
});
});
@@ -1,6 +1,6 @@
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template"; import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { WEBAPP_URL } from "@formbricks/lib/constants"; import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { getStyling } from "@formbricks/lib/utils/styling"; import { getStyling } from "@formbricks/lib/utils/styling";
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
} }
const styling = getStyling(project, survey); const styling = getStyling(project, survey);
const surveyUrl = WEBAPP_URL + "/s/" + survey.id; const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t); const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype = const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">'; '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
@@ -0,0 +1,36 @@
import { Options } from "qr-code-styling";
export const getQRCodeOptions = (width: number, height: number): Options => ({
width,
height,
type: "svg",
data: "",
margin: 0,
qrOptions: {
typeNumber: 0,
mode: "Byte",
errorCorrectionLevel: "L",
},
imageOptions: {
saveAsBlob: true,
hideBackgroundDots: false,
imageSize: 0,
margin: 0,
},
dotsOptions: {
type: "extra-rounded",
color: "#000000",
roundSize: true,
},
backgroundOptions: {
color: "#ffffff",
},
cornersSquareOptions: {
type: "dot",
color: "#000000",
},
cornersDotOptions: {
type: "dot",
color: "#000000",
},
});
@@ -0,0 +1,44 @@
"use client";
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
import { useTranslate } from "@tolgee/react";
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef } from "react";
import { toast } from "react-hot-toast";
export const useSurveyQRCode = (surveyUrl: string) => {
const qrCodeRef = useRef<HTMLDivElement>(null);
const qrInstance = useRef<QRCodeStyling | null>(null);
const { t } = useTranslate();
useEffect(() => {
try {
if (!qrInstance.current) {
qrInstance.current = new QRCodeStyling(getQRCodeOptions(70, 70));
}
if (surveyUrl && qrInstance.current) {
qrInstance.current.update({ data: surveyUrl });
if (qrCodeRef.current) {
qrCodeRef.current.innerHTML = "";
qrInstance.current.append(qrCodeRef.current);
}
}
} catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
}
}, [surveyUrl]);
const downloadQRCode = () => {
try {
const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500));
downloadInstance.update({ data: surveyUrl });
downloadInstance.download({ name: "survey-qr", extension: "png" });
} catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
}
};
return { qrCodeRef, downloadQRCode };
};
@@ -3,14 +3,12 @@ import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/s
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage"; import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
@@ -18,11 +16,7 @@ import {
MAX_RESPONSES_FOR_INSIGHT_GENERATION, MAX_RESPONSES_FOR_INSIGHT_GENERATION,
WEBAPP_URL, WEBAPP_URL,
} from "@formbricks/lib/constants"; } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service"; import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
@@ -30,10 +24,8 @@ import { getUser } from "@formbricks/lib/user/service";
const SurveyPage = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => { const SurveyPage = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) { const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
throw new Error(t("common.session_not_found"));
}
const surveyId = params.surveyId; const surveyId = params.surveyId;
@@ -41,41 +33,20 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
return notFound(); return notFound();
} }
const [survey, environment] = await Promise.all([ const survey = await getSurvey(params.surveyId);
getSurvey(params.surveyId),
getEnvironment(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!survey) { if (!survey) {
throw new Error(t("common.survey_not_found")); throw new Error(t("common.survey_not_found"));
} }
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId); const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
// I took this out cause it's cloud only right? // I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense(); // const { active: isEnterpriseEdition } = await getEnterpriseLicense();
@@ -84,6 +55,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
billing: organization.billing, billing: organization.billing,
}); });
const shouldGenerateInsights = needsInsightsGeneration(survey); const shouldGenerateInsights = needsInsightsGeneration(survey);
const surveyDomain = getSurveyDomain();
return ( return (
<PageContentWrapper> <PageContentWrapper>
@@ -94,8 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
environment={environment} environment={environment}
survey={survey} survey={survey}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
webAppUrl={WEBAPP_URL}
user={user} user={user}
surveyDomain={surveyDomain}
/> />
}> }>
{isAIEnabled && shouldGenerateInsights && ( {isAIEnabled && shouldGenerateInsights && (
@@ -124,6 +96,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE} locale={user.locale ?? DEFAULT_LOCALE}
/> />
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
</PageContentWrapper> </PageContentWrapper>
); );
}; };
+87
View File
@@ -0,0 +1,87 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getUser } from "@formbricks/lib/user/service";
import { TUser } from "@formbricks/types/user";
import AppLayout from "./layout";
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
}));
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
describe("(app) AppLayout", () => {
afterEach(() => {
cleanup();
});
it("renders child content and all sub-components when user exists", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
// Because AppLayout is async, call it like a function
const element = await AppLayout({
children: <div data-testid="child-content">Hello from children</div>,
});
render(element);
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
});
it("skips FormbricksClient if no user is present", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const element = await AppLayout({
children: <div data-testid="child-content">Hello from children</div>,
});
render(element);
expect(screen.queryByTestId("formbricks-client")).not.toBeInTheDocument();
});
});
+15 -9
View File
@@ -1,32 +1,38 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient"; import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClient } from "@/app/IntercomClient"; import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client"; import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Suspense } from "react"; import { Suspense } from "react";
import { INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants"; import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
const AppLayout = async ({ children }) => { const AppLayout = async ({ children }) => {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null; const user = session?.user?.id ? await getUser(session.user.id) : null;
// If user account is deactivated, log them out instead of rendering the app
if (user?.isActive === false) {
return <ClientLogout />;
}
return ( return (
<> <>
<NoMobileOverlay /> <NoMobileOverlay />
<Suspense> <Suspense>
<PostHogPageview /> <PostHogPageview
posthogEnabled={IS_POSTHOG_CONFIGURED}
postHogApiHost={POSTHOG_API_HOST}
postHogApiKey={POSTHOG_API_KEY}
/>
</Suspense> </Suspense>
<PHProvider> <PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<> <>
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null} {user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
<IntercomClient <IntercomClientWrapper user={user} />
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
intercomSecretKey={INTERCOM_SECRET_KEY}
user={user}
/>
<ToasterClient /> <ToasterClient />
{children} {children}
</> </>
+34
View File
@@ -0,0 +1,34 @@
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import AppLayout from "../(auth)/layout";
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_INTERCOM_CONFIGURED: true,
INTERCOM_SECRET_KEY: "mock-intercom-secret-key",
INTERCOM_APP_ID: "mock-intercom-app-id",
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
}));
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="mock-no-mobile-overlay" />,
}));
describe("(auth) AppLayout", () => {
it("renders the NoMobileOverlay and IntercomClient, plus children", async () => {
const appLayoutElement = await AppLayout({
children: <div data-testid="child-content">Hello from children!</div>,
});
const childContentText = "Hello from children!";
render(appLayoutElement);
expect(screen.getByTestId("mock-no-mobile-overlay")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent(childContentText);
});
});
+2 -3
View File
@@ -1,12 +1,11 @@
import { IntercomClient } from "@/app/IntercomClient"; import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants";
const AppLayout = async ({ children }) => { const AppLayout = async ({ children }) => {
return ( return (
<> <>
<NoMobileOverlay /> <NoMobileOverlay />
<IntercomClient isIntercomConfigured={IS_INTERCOM_CONFIGURED} intercomSecretKey={INTERCOM_SECRET_KEY} /> <IntercomClientWrapper />
{children} {children}
</> </>
); );
+2 -1
View File
@@ -2,6 +2,7 @@ import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getShortUrl } from "@formbricks/lib/shortUrl/service"; import { getShortUrl } from "@formbricks/lib/shortUrl/service";
import { logger } from "@formbricks/logger";
import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url"; import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url";
export const generateMetadata = async (props): Promise<Metadata> => { export const generateMetadata = async (props): Promise<Metadata> => {
@@ -44,7 +45,7 @@ const Page = async (props) => {
try { try {
shortUrl = await getShortUrl(params.shortUrlId); shortUrl = await getShortUrl(params.shortUrlId);
} catch (error) { } catch (error) {
console.error(error); logger.error(error, "Could not fetch short url");
notFound(); notFound();
} }
@@ -1,50 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { AsyncParser } from "@json2csv/node";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
export const POST = async (request: NextRequest) => {
const session = await getServerSession(authOptions);
if (!session) {
return responses.unauthorizedResponse();
}
const data = await request.json();
let csv: string = "";
const { json, fields, fileName } = data;
const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
const encodedFileName = encodeURIComponent(fileName)
.replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
.replace(/\*/g, "%2A");
const parser = new AsyncParser({
fields,
});
try {
csv = await parser.parse(json).promise();
} catch (err) {
console.error(err);
throw new Error("Failed to convert to CSV");
}
const headers = new Headers();
headers.set("Content-Type", "text/csv;charset=utf-8;");
headers.set(
"Content-Disposition",
`attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
);
return Response.json(
{
fileResponse: csv,
},
{
headers,
}
);
};
@@ -1,46 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as xlsx from "xlsx";
export const POST = async (request: NextRequest) => {
const session = await getServerSession(authOptions);
if (!session) {
return responses.unauthorizedResponse();
}
const data = await request.json();
const { json, fields, fileName } = data;
const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
const encodedFileName = encodeURIComponent(fileName)
.replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
.replace(/\*/g, "%2A");
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(json, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
const buffer = xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
const base64String = buffer.toString("base64");
const headers = new Headers();
headers.set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
headers.set(
"Content-Disposition",
`attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
);
return Response.json(
{
fileResponse: base64String,
},
{
headers,
}
);
};
@@ -0,0 +1,390 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { mockSurveyOutput } from "@formbricks/lib/survey/tests/__mock__/survey.mock";
import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
doesResponseHasAnyOpenTextAnswer,
generateInsightsEnabledForSurveyQuestions,
generateInsightsForSurvey,
} from "./utils";
// Mock all dependencies
vi.mock("@formbricks/lib/constants", () => ({
CRON_SECRET: vi.fn(() => "mocked-cron-secret"),
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
vi.mock("@formbricks/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@formbricks/lib/survey/service", () => ({
getSurvey: vi.fn(),
updateSurvey: vi.fn(),
}));
vi.mock("@formbricks/lib/survey/utils", () => ({
doesSurveyHasOpenTextQuestion: vi.fn(),
}));
vi.mock("@formbricks/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("Insights Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("generateInsightsForSurvey", () => {
test("should call fetch with correct parameters", () => {
const surveyId = "survey-123";
mockFetch.mockResolvedValueOnce({ ok: true });
generateInsightsForSurvey(surveyId);
expect(mockFetch).toHaveBeenCalledWith(`${WEBAPP_URL}/api/insights`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
surveyId,
}),
});
});
test("should handle errors and return error object", () => {
const surveyId = "survey-123";
mockFetch.mockImplementationOnce(() => {
throw new Error("Network error");
});
const result = generateInsightsForSurvey(surveyId);
expect(result).toEqual({
ok: false,
error: new Error("Error while generating insights for survey: Network error"),
});
});
test("should throw error if CRON_SECRET is not set", async () => {
// Reset modules to ensure clean state
vi.resetModules();
// Mock CRON_SECRET as undefined
vi.doMock("@formbricks/lib/constants", () => ({
CRON_SECRET: undefined,
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
// Re-import the utils module to get the mocked CRON_SECRET
const { generateInsightsForSurvey } = await import("./utils");
expect(() => generateInsightsForSurvey("survey-123")).toThrow("CRON_SECRET is not set");
// Reset modules after test
vi.resetModules();
});
});
describe("generateInsightsEnabledForSurveyQuestions", () => {
test("should return success=false when survey has no open text questions", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Question 2" },
required: true,
scale: "number",
range: 5,
isColorCodingEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(false);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should return success=true when survey is updated with insights enabled", async () => {
vi.clearAllMocks();
// Mock data
const surveyId = "cm8ckvchx000008lb710n0gdn";
// Mock survey with open text questions that have no insightsEnabled property
const mockSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyOutput,
id: surveyId,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 2" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Define the updated survey that should be returned after updateSurvey
const mockUpdatedSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyWithOpenTextQuestions,
questions: mockSurveyWithOpenTextQuestions.questions.map((q) => ({
...q,
insightsEnabled: true, // Updated property
})),
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurveyWithOpenTextQuestions);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockUpdatedSurveyWithOpenTextQuestions);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
expect(result).toEqual({
success: true,
survey: mockUpdatedSurveyWithOpenTextQuestions,
});
});
test("should return success=false when all open text questions already have insightsEnabled defined", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: true,
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if survey is not found", async () => {
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(null);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(
new ResourceNotFoundError("Survey", "survey-123")
);
});
test("should throw ResourceNotFoundError if updateSurvey returns null", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Type assertion to handle the null case
vi.mocked(updateSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions(surveyId)).rejects.toThrow(
new ResourceNotFoundError("Survey", surveyId)
);
});
test("should return success=false when no questions have insights enabled after update", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockSurvey);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
});
test("should propagate any errors that occur", async () => {
// Setup mocks
const testError = new Error("Test error");
vi.mocked(getSurvey).mockRejectedValueOnce(testError);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(testError);
});
});
describe("doesResponseHasAnyOpenTextAnswer", () => {
test("should return true when at least one open text question has an answer", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "This is an answer",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(true);
});
test("should return false when no open text questions have answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false when response does not contain any open text question IDs", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q4: "This is not an open text answer",
q5: "Another answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false for non-string answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: 123,
q3: true,
} as any; // Use type assertion to handle mixed types in the test
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
});
});
@@ -4,12 +4,17 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils"; import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate"; import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
export const generateInsightsForSurvey = (surveyId: string) => { export const generateInsightsForSurvey = (surveyId: string) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
}
try { try {
return fetch(`${WEBAPP_URL}/api/insights`, { return fetch(`${WEBAPP_URL}/api/insights`, {
method: "POST", method: "POST",
@@ -80,7 +85,7 @@ export const generateInsightsEnabledForSurveyQuestions = async (
return { success: false }; return { success: false };
} catch (error) { } catch (error) {
console.error("Error generating insights for surveys:", error); logger.error(error, "Error generating insights for surveys");
throw error; throw error;
} }
}; };
@@ -5,6 +5,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { z } from "zod"; import { z } from "zod";
import { CRON_SECRET } from "@formbricks/lib/constants"; import { CRON_SECRET } from "@formbricks/lib/constants";
import { logger } from "@formbricks/logger";
import { generateInsightsEnabledForSurveyQuestions } from "./lib/utils"; import { generateInsightsEnabledForSurveyQuestions } from "./lib/utils";
export const maxDuration = 300; // This function can run for a maximum of 300 seconds export const maxDuration = 300; // This function can run for a maximum of 300 seconds
@@ -25,7 +26,7 @@ export const POST = async (request: Request) => {
const inputValidation = ZGenerateInsightsInput.safeParse(jsonInput); const inputValidation = ZGenerateInsightsInput.safeParse(jsonInput);
if (!inputValidation.success) { if (!inputValidation.success) {
console.error(inputValidation.error); logger.error({ error: inputValidation.error, url: request.url }, "Error in POST /api/insights");
return responses.badRequestResponse( return responses.badRequestResponse(
"Fields are missing or incorrectly formatted", "Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error), transformErrorToDetails(inputValidation.error),
@@ -9,6 +9,7 @@ import { writeDataToSlack } from "@formbricks/lib/slack/service";
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime"; import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
import { parseRecallInfo } from "@formbricks/lib/utils/recall"; import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { truncateText } from "@formbricks/lib/utils/strings"; import { truncateText } from "@formbricks/lib/utils/strings";
import { logger } from "@formbricks/logger";
import { Result } from "@formbricks/types/error-handlers"; import { Result } from "@formbricks/types/error-handlers";
import { TIntegration, TIntegrationType } from "@formbricks/types/integration"; import { TIntegration, TIntegrationType } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -83,13 +84,13 @@ export const handleIntegrations = async (
survey survey
); );
if (!googleResult.ok) { if (!googleResult.ok) {
console.error("Error in google sheets integration: ", googleResult.error); logger.error(googleResult.error, "Error in google sheets integration");
} }
break; break;
case "slack": case "slack":
const slackResult = await handleSlackIntegration(integration as TIntegrationSlack, data, survey); const slackResult = await handleSlackIntegration(integration as TIntegrationSlack, data, survey);
if (!slackResult.ok) { if (!slackResult.ok) {
console.error("Error in slack integration: ", slackResult.error); logger.error(slackResult.error, "Error in slack integration");
} }
break; break;
case "airtable": case "airtable":
@@ -99,13 +100,13 @@ export const handleIntegrations = async (
survey survey
); );
if (!airtableResult.ok) { if (!airtableResult.ok) {
console.error("Error in airtable integration: ", airtableResult.error); logger.error(airtableResult.error, "Error in airtable integration");
} }
break; break;
case "notion": case "notion":
const notionResult = await handleNotionIntegration(integration as TIntegrationNotion, data, survey); const notionResult = await handleNotionIntegration(integration as TIntegrationNotion, data, survey);
if (!notionResult.ok) { if (!notionResult.ok) {
console.error("Error in notion integration: ", notionResult.error); logger.error(notionResult.error, "Error in notion integration");
} }
break; break;
} }
@@ -418,7 +419,7 @@ const getValue = (colType: string, value: string | string[] | Date | number | Re
return typeof value === "string" ? value : (value as string[]).join(", "); return typeof value === "string" ? value : (value as string[]).join(", ");
} }
} catch (error) { } catch (error) {
console.error(error); logger.error(error, "Payload build failed!");
throw new Error("Payload build failed!"); throw new Error("Payload build failed!");
} }
}; };
@@ -1,6 +1,7 @@
import { sendFollowUpEmail } from "@/modules/email"; import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod"; import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up"; import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
@@ -89,6 +90,6 @@ export const sendSurveyFollowUps = async (
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`); .map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) { if (errors.length > 0) {
console.error("Follow-up processing errors:", errors); logger.error(errors, "Follow-up processing errors");
} }
}; };
+14 -7
View File
@@ -19,6 +19,7 @@ import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time"; import { convertDatesInObject } from "@formbricks/lib/time";
import { getPromptText } from "@formbricks/lib/utils/ai"; import { getPromptText } from "@formbricks/lib/utils/ai";
import { parseRecallInfo } from "@formbricks/lib/utils/recall"; import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { logger } from "@formbricks/logger";
import { handleIntegrations } from "./lib/handleIntegrations"; import { handleIntegrations } from "./lib/handleIntegrations";
export const POST = async (request: Request) => { export const POST = async (request: Request) => {
@@ -34,7 +35,10 @@ export const POST = async (request: Request) => {
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput); const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
if (!inputValidation.success) { if (!inputValidation.success) {
console.error(inputValidation.error); logger.error(
{ error: inputValidation.error, url: request.url },
"Error in POST /api/(internal)/pipeline"
);
return responses.badRequestResponse( return responses.badRequestResponse(
"Fields are missing or incorrectly formatted", "Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error), transformErrorToDetails(inputValidation.error),
@@ -87,7 +91,7 @@ export const POST = async (request: Request) => {
data: response, data: response,
}), }),
}).catch((error) => { }).catch((error) => {
console.error(`Webhook call to ${webhook.url} failed:`, error); logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
}) })
); );
@@ -100,7 +104,7 @@ export const POST = async (request: Request) => {
]); ]);
if (!survey) { if (!survey) {
console.error(`Survey with id ${surveyId} not found`); logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return new Response("Survey not found", { status: 404 }); return new Response("Survey not found", { status: 404 });
} }
@@ -172,7 +176,10 @@ export const POST = async (request: Request) => {
const emailPromises = usersWithNotifications.map((user) => const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => { sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
console.error(`Failed to send email to ${user.email}:`, error); logger.error(
{ error, url: request.url, userEmail: user.email },
`Failed to send email to ${user.email}`
);
}) })
); );
@@ -188,7 +195,7 @@ export const POST = async (request: Request) => {
const results = await Promise.allSettled([...webhookPromises, ...emailPromises]); const results = await Promise.allSettled([...webhookPromises, ...emailPromises]);
results.forEach((result) => { results.forEach((result) => {
if (result.status === "rejected") { if (result.status === "rejected") {
console.error("Promise rejected:", result.reason); logger.error({ error: result.reason, url: request.url }, "Promise rejected");
} }
}); });
@@ -228,7 +235,7 @@ export const POST = async (request: Request) => {
text, text,
}); });
} catch (e) { } catch (e) {
console.error(e); logger.error({ error: e, url: request.url }, "Error creating document and assigning insight");
} }
} }
} }
@@ -240,7 +247,7 @@ export const POST = async (request: Request) => {
const results = await Promise.allSettled(webhookPromises); const results = await Promise.allSettled(webhookPromises);
results.forEach((result) => { results.forEach((result) => {
if (result.status === "rejected") { if (result.status === "rejected") {
console.error("Promise rejected:", result.reason); logger.error({ error: result.reason, url: request.url }, "Promise rejected");
} }
}); });
} }
+178
View File
@@ -0,0 +1,178 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { authenticateRequest } from "./auth";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("getApiKeyWithPermissions", () => {
it("should return API key data with permissions when valid key is provided", async () => {
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
hashedKey: "hashed-key",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "manage" as const,
environment: { id: "env-1" },
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await getApiKeyWithPermissions("test-api-key");
expect(result).toEqual(mockApiKeyData);
expect(prisma.apiKey.update).toHaveBeenCalledWith({
where: { id: "api-key-id" },
data: { lastUsedAt: expect.any(Date) },
});
});
it("should return null when API key is not found", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getApiKeyWithPermissions("invalid-key");
expect(result).toBeNull();
});
});
describe("hasPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
projectId: "project-2",
projectName: "Project 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "development",
projectId: "project-3",
projectName: "Project 3",
},
];
it("should return true for manage permission with any method", () => {
expect(hasPermission(permissions, "env-1", "GET")).toBe(true);
expect(hasPermission(permissions, "env-1", "POST")).toBe(true);
expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true);
});
it("should handle write permission correctly", () => {
expect(hasPermission(permissions, "env-2", "GET")).toBe(true);
expect(hasPermission(permissions, "env-2", "POST")).toBe(true);
expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false);
});
it("should handle read permission correctly", () => {
expect(hasPermission(permissions, "env-3", "GET")).toBe(true);
expect(hasPermission(permissions, "env-3", "POST")).toBe(false);
expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false);
});
it("should return false for non-existent environment", () => {
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
});
});
describe("authenticateRequest", () => {
it("should return authentication data for valid API key", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
hashedKey: "hashed-key",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "manage" as const,
environment: {
id: "env-1",
projectId: "project-1",
project: { name: "Project 1" },
type: "development",
},
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await authenticateRequest(request);
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
});
});
it("should return null when no API key is provided", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
it("should return null when API key is invalid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
});
+28 -15
View File
@@ -1,25 +1,38 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => { export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key"); const apiKey = request.headers.get("x-api-key");
if (apiKey) { if (!apiKey) return null;
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (environmentId) { // Get API key with permissions
const hashedApiKey = hashApiKey(apiKey); const apiKeyData = await getApiKeyWithPermissions(apiKey);
const authentication: TAuthenticationApiKey = { if (!apiKeyData) return null;
type: "apiKey",
environmentId, // In the route handlers, we'll do more specific permission checks
hashedApiKey, const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
}; if (environmentIds.length === 0) return null;
return authentication;
} const hashedApiKey = hashApiKey(apiKey);
return null; const authentication: TAuthenticationApiKey = {
} type: "apiKey",
return null; environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return authentication;
}; };
export const handleErrorResponse = (error: any): Response => { export const handleErrorResponse = (error: any): Response => {
@@ -21,6 +21,7 @@ import {
} from "@formbricks/lib/posthogServer"; } from "@formbricks/lib/posthogServer";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { logger } from "@formbricks/logger";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js"; import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
@@ -103,7 +104,7 @@ export const GET = async (
}, },
}); });
} catch (error) { } catch (error) {
console.error(`Error sending plan limits reached event to Posthog: ${error}`); logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`);
} }
} }
} }
@@ -187,7 +188,10 @@ export const GET = async (
return responses.successResponse({ ...state }, true); return responses.successResponse({ ...state }, true);
} catch (error) { } catch (error) {
console.error(error); logger.error(
{ error, url: request.url },
"Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"
);
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true); return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
} }
}; };
@@ -14,6 +14,7 @@ import { getSurveys } from "@formbricks/lib/survey/service";
import { anySurveyHasFilters } from "@formbricks/lib/survey/utils"; import { anySurveyHasFilters } from "@formbricks/lib/survey/utils";
import { diffInDays } from "@formbricks/lib/utils/datetime"; import { diffInDays } from "@formbricks/lib/utils/datetime";
import { validateInputs } from "@formbricks/lib/utils/validate"; import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
@@ -150,7 +151,7 @@ export const getSyncSurveys = reactCache(
return surveys; return surveys;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error); logger.error(error);
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
} }

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