Compare commits

...

79 Commits

Author SHA1 Message Date
pandeymangg 7ebc5e7a9c Merge remote-tracking branch 'origin/main' into harsh/ios-sdk-docs 2025-04-21 13:47:38 +05:30
Piyush Gupta 302c6a90c0 chore: removes formbricks_encryption_key-environment-variable (#5426) 2025-04-21 05:55:48 +00:00
harshsbhat f99e85cc1a docs: add swift sdk docs 2025-04-20 15:06:49 +05:30
Anshuman Pandey 18e597d8a3 fix: smaller fixes and tweaks (#5417) 2025-04-18 12:26:21 +00:00
victorvhs017 81d717ccff fix: iOS SDK memory leaks (#5388)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-17 18:03:05 +00:00
Dhruwang Jariwala 2e979c7323 fix: managers should not be allowed to create api keys (#5409) 2025-04-17 14:12:55 +00:00
Anshuman Pandey 4dfd15d6dd fix: adds no-cache header when debug mode is ON (#5405) 2025-04-17 09:03:44 +00:00
Matti Nannt 5b9bf3ff43 chore(infra): increase cloudwatch elb alarm limit to 10 (#5407) 2025-04-17 06:41:13 +00:00
Anshuman Pandey d2f7485098 feat: advanced follow ups (#5340)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-04-17 06:39:22 +00:00
Dhruwang Jariwala f8fee1fba7 fix: refactor end screen card description ux (#5386)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-17 06:28:18 +00:00
Matti Nannt 19249ca00f chore: enable performance insights for rds (#5404) 2025-04-17 06:27:34 +00:00
Anshuman Pandey 01e5700340 fix: adds eslint rules for using test and refactors the current tests (#5397) 2025-04-17 03:32:03 +00:00
Johannes ff2f7660a6 chore: add segment id to modal view (#5391)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-17 02:56:32 +00:00
Johannes 2bc05e2b4a docs: tweak quick start guides (#5403) 2025-04-16 19:38:54 -07:00
Johannes 137c6447b7 docs: tweak Data Prefilling docs for clarity (#5402) 2025-04-16 18:43:00 -07:00
Piyush Gupta ebc8f0c917 fix: docker build test (#5396) 2025-04-16 12:32:38 +00:00
Anshuman Pandey 5a8d10b5b4 fix: removes the onFinished callbacks from the iOS package (#5384) 2025-04-16 11:11:30 +00:00
Anshuman Pandey 875815fb62 fix: fixes cb urls (#5392) 2025-04-16 04:47:14 +00:00
Dhruwang Jariwala cdf526e130 fix: type issue in notion integration (#5385) 2025-04-16 04:15:27 +00:00
Dhruwang Jariwala b685032b34 chore: make env permissions optional in api key (#5309)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-15 11:42:48 +00:00
Vijay a171f9cb00 fix: security hotspots in Dockerfile (#5314) 2025-04-15 04:15:56 -07:00
Dhruwang Jariwala c452f05ec2 feat: questionid to summary (#5381) 2025-04-15 05:58:10 +00:00
Dhruwang Jariwala 93d91f80f2 fix: progress bar calculation (#5339) 2025-04-15 00:51:47 +00:00
Piyush Gupta 7b764c8427 fix: adds api_key label to the view permission modal (#5326) 2025-04-14 08:53:14 +00:00
Piyush Gupta 016289c8cb fix: download responses button label (#5324)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-14 07:10:40 +00:00
Dhruwang Jariwala 93a9575389 fix: missing translation in api key modal (#5341) 2025-04-14 07:02:43 +00:00
victorvhs017 9e265adf14 chore: add test files to modules/account and modules/analysis (#5294)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-13 19:04:56 +00:00
Dhruwang Jariwala eb08a0ed14 fix: buttonLabel conditions (#5336)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-13 08:36:59 +00:00
Dhruwang Jariwala c533f37983 chore: improve accessibility for matrix question (#5320)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-12 04:40:24 +00:00
Anshuman Pandey ca4f8385e4 fix: adds FormbricksEnvironment struct for url constants (#5312) 2025-04-11 13:44:25 +00:00
Matti Nannt 3eb9aa74ed chore: upgrade typescript and react dependencies (#5317) 2025-04-11 13:01:54 +02:00
Piyush Gupta 637b51464c docs: updates the API keys docs in API reference (#5319) 2025-04-11 08:46:04 +00:00
Dhruwang Jariwala fd9585a66e fix: respondent should not see redirect card text (#5239)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-11 04:22:25 +00:00
Matti Nannt 49ecbcb0c9 fix: updatedAt not set in response update (#5315) 2025-04-10 11:04:42 +00:00
Piyush Gupta 1132bdd66a fix: openAPI spec for contact endpoints (#5247)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-10 10:22:40 +00:00
Anshuman Pandey c7d6ed9ea3 chore: removes api package and deps (#5251)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-10 09:41:39 +00:00
Matti Nannt 782528f169 chore: update surveys package npm dependencies (#5302) 2025-04-10 10:44:56 +02:00
Piyush Gupta 104c78275f docs: fixes framework guide link (#5307) 2025-04-10 08:11:40 +00:00
Matti Nannt d9d88f7175 chore: update eslint npm dependencies (#5313) 2025-04-10 10:22:58 +02:00
Dhruwang Jariwala bf7e24cf11 fix: stripe issue for customers with existing stripe ID (#5308) 2025-04-10 07:56:01 +00:00
Anshuman Pandey c8aba01db3 fix: adds isWebEnvironment check in the surveys package (#5310) 2025-04-10 09:01:36 +02:00
Piyush Gupta a896c7e46e docs: updated API playground link in the webhooks docs (#5301) 2025-04-09 08:33:36 +00:00
Matti Nannt 8018ec14a2 chore: use remote turbocache for building formbricks (#5305) 2025-04-09 10:38:17 +02:00
victorvhs017 9c3208c860 chore: Refactored the Formbricks next public env variables and added test files (#5014)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 08:10:32 +00:00
Anshuman Pandey e1063964cf fix: fixes segment self referencing issue (#5254)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 06:58:28 +00:00
victorvhs017 38568738cc feat: Added test configuration and initial test files to the surveys package (#5253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-09 06:53:16 +00:00
Piyush Gupta 15b8358b14 fix: date format in response table (#5304) 2025-04-09 05:39:57 +00:00
Anshuman Pandey 2173cb2610 fix: removes sourcemaps (#5257) 2025-04-09 04:50:56 +00:00
Matti Nannt 87b925d622 chore: update apps/web npm dependencies (#5300) 2025-04-09 06:58:53 +02:00
Piyush Gupta 885b06cc26 fix: adds date value check in date question summary (#5296) 2025-04-09 04:07:39 +00:00
Matti Nannt adb6a5f41e chore: upgrade npm dependencies (#5299) 2025-04-09 04:47:07 +02:00
Matti Nannt 3b815e22e3 chore: add docker build check github action (#4875)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-08 13:26:48 +00:00
Matti Nannt 4d4a5c0e64 fix: solve sonarqube security hotspots (#5292) 2025-04-08 14:58:24 +02:00
Anshuman Pandey 0e89293974 fix: appUrl fix in iOS and android packages (#5295) 2025-04-08 14:51:30 +02:00
Jakob Schott c306911b3a fix: replace hard-coded alerts with alert component (#5156)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-08 10:26:28 +00:00
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
551 changed files with 29501 additions and 11606 deletions
+3 -4
View File
@@ -117,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
@@ -155,9 +155,8 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks # Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST= FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID= FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Oauth credentials for Google sheet integration # Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID= GOOGLE_SHEETS_CLIENT_ID=
+11 -1
View File
@@ -8,6 +8,14 @@ on:
required: false required: false
default: "0" default: "0"
inputs:
turbo_token:
description: "Turborepo token"
required: false
turbo_team:
description: "Turborepo team"
required: false
runs: runs:
using: "composite" using: "composite"
steps: steps:
@@ -62,6 +70,8 @@ runs:
- run: | - run: |
pnpm build --filter=@formbricks/web... pnpm build --filter=@formbricks/web...
if: steps.cache-build.outputs.cache-hit != 'true' if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash shell: bash
env:
TURBO_TOKEN: ${{ inputs.turbo_token }}
TURBO_TEAM: ${{ inputs.turbo_team }}
+3 -1
View File
@@ -4,7 +4,7 @@ on:
permissions: permissions:
contents: read contents: read
jobs: jobs:
build: build:
name: Build Formbricks-web name: Build Formbricks-web
@@ -25,3 +25,5 @@ jobs:
id: cache-build-web id: cache-build-web
with: with:
e2e_testing_mode: "0" e2e_testing_mode: "0"
turbo_token: ${{ secrets.TURBO_TOKEN }}
turbo_team: ${{ vars.TURBO_TEAM }}
@@ -0,0 +1,167 @@
name: Docker Build Validation
on:
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
validate-docker-build:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL service container
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: formbricks
ports:
- 5432:5432
# Health check to ensure PostgreSQL is ready before using it
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
run: |
echo "🧪 Testing if the Docker image starts correctly..."
# Add extra docker run args to support host.docker.internal on Linux
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
# Start the container with host.docker.internal pointing to the host
docker run --name formbricks-test \
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
set +e # Disable exit on error to allow for retries
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
echo "📋 Full container logs:"
docker logs formbricks-test
# Clean up the container
echo "🧹 Cleaning up..."
docker rm -f formbricks-test
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi
echo "✨ Docker validation complete - all checks passed!"
+2
View File
@@ -16,6 +16,8 @@ on:
env: env:
TELEMETRY_DISABLED: 1 TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions: permissions:
id-token: write id-token: write
+3 -3
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
@@ -48,7 +48,7 @@ jobs:
run: | run: |
pnpm test:coverage pnpm test:coverage
- 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 }}
+9
View File
@@ -1,4 +1,13 @@
{ {
"github.copilot.chat.codeGeneration.instructions": [
{
"text": "When generating tests, always use vitest and use the `test` function instead of `it`."
}
],
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",
"projectKey": "formbricks_formbricks"
},
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib" "typescript.tsdk": "node_modules/typescript/lib"
} }
+4 -4
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 pt-5 pb-4">
<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">
@@ -38,10 +38,10 @@ export function Sidebar(): React.JSX.Element {
href={item.href} href={item.href}
className={classNames( className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white", item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"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 leading-6 font-medium"
)} )}
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>
))} ))}
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
<a <a
key={item.name} key={item.name}
href={item.href} href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white"> className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" /> <item.icon className="mr-4 h-6 w-6 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);
}
}
+9 -5
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.2.3", "@tailwindcss/postcss": "4.1.3",
"react": "19.0.0", "lucide-react": "0.486.0",
"react-dom": "19.0.0" "next": "15.2.4",
"postcss": "8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
+2 -2
View File
@@ -96,10 +96,10 @@ 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="mt-4 rounded-xs" 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:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<div className="flex items-center"> <div className="flex items-center">
<strong className="w-32 truncate sm:w-auto"> <strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID} {process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
+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")],
};
+13 -13
View File
@@ -18,23 +18,23 @@
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "3.2.6", "@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.11", "@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.11", "@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.11", "@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.11", "@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.11", "@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.11", "@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.11", "@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.11", "@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.11", "@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.0", "@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.0", "@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2", "esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0", "eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"storybook": "8.6.11", "storybook": "8.6.12",
"tsup": "8.4.0", "tsup": "8.4.0",
"vite": "6.2.4" "vite": "6.2.5"
} }
} }
+47 -19
View File
@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable 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 cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values # BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions # This approach relies entirely on secrets passed from GitHub Actions
@@ -40,8 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \ echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS} ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -83,35 +81,65 @@ RUN corepack enable
RUN apk add --no-cache curl \ RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \ && apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \ # && addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
WORKDIR /home/nextjs WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer /app/apps/web/.next/standalone ./
RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs . COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json . COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size RUN chmod 644 ./package.json
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./ COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./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/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/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 --from=installer /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
# 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
RUN chmod -R 755 ./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
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty RUN npm install -g tsx typescript prisma pino-pretty
@@ -112,7 +112,7 @@ export const LandingSidebar = ({
{/* Dropdown Items */} {/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map((link) => (
<Link href={link.href} target={link.target} className="flex w-full items-center"> <Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
<DropdownMenuItem> <DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} /> <link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label} {link.label}
@@ -3,7 +3,7 @@ import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import React from "react"; import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
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";
@@ -71,7 +71,7 @@ describe("ProjectOnboardingLayout", () => {
cleanup(); cleanup();
}); });
it("redirects to /auth/login if there is no session", async () => { test("redirects to /auth/login if there is no session", async () => {
// Mock no session // Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null); vi.mocked(getServerSession).mockResolvedValueOnce(null);
@@ -85,7 +85,7 @@ describe("ProjectOnboardingLayout", () => {
expect(layoutElement).toBeUndefined(); expect(layoutElement).toBeUndefined();
}); });
it("throws an error if user does not exist", async () => { test("throws an error if user does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" }, user: { id: "user-123" },
}); });
@@ -99,7 +99,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.user_not_found"); ).rejects.toThrow("common.user_not_found");
}); });
it("throws AuthorizationError if user cannot access organization", async () => { test("throws AuthorizationError if user cannot access organization", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false); vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
@@ -112,7 +112,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.not_authorized"); ).rejects.toThrow("common.not_authorized");
}); });
it("throws an error if organization does not exist", async () => { test("throws an error if organization does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true); vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
@@ -126,7 +126,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.organization_not_found"); ).rejects.toThrow("common.organization_not_found");
}); });
it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => { test("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
// Provide valid data // Provide valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);
@@ -1,191 +1,120 @@
import "@testing-library/jest-dom/vitest"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { act, cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth"; import { Session } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import React from "react"; import { afterEach, describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
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 { getUser } from "@formbricks/lib/user/service";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout"; import SurveyEditorEnvironmentLayout from "./layout";
// mock all dependencies // Mock sub-components to render identifiable elements
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
vi.mock("@formbricks/lib/constants", () => ({ EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
IS_FORMBRICKS_CLOUD: false, <div data-testid="EnvironmentIdBaseLayout">
POSTHOG_API_KEY: "mock-posthog-api-key", {environmentId}
POSTHOG_HOST: "mock-posthog-host", {children}
IS_POSTHOG_CONFIGURED: true, </div>
ENCRYPTION_KEY: "mock-encryption-key", ),
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", }));
GITHUB_ID: "mock-github-id", vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
GITHUB_SECRET: "test-githubID", DevEnvironmentBanner: ({ environment }: any) => (
GOOGLE_CLIENT_ID: "test-google-client-id", <div data-testid="DevEnvironmentBanner">{environment.id}</div>
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", () => ({ // Mocks for dependencies
getServerSession: vi.fn(), vi.mock("@/modules/environments/lib/utils", () => ({
})); environmentIdLayoutChecks: vi.fn(),
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
})); }));
vi.mock("@formbricks/lib/environment/service", () => ({ vi.mock("@formbricks/lib/environment/service", () => ({
getEnvironment: vi.fn(), getEnvironment: vi.fn(),
})); }));
vi.mock("@formbricks/lib/organization/service", () => ({ vi.mock("next/navigation", () => ({
getOrganizationByEnvironmentId: vi.fn(), redirect: 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", () => { describe("SurveyEditorEnvironmentLayout", () => {
beforeEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("redirects to /auth/login if there is no session", async () => { test("renders successfully when environment is found", async () => {
// Mock no session vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
vi.mocked(getServerSession).mockResolvedValueOnce(null); t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
const layoutElement = await SurveyEditorEnvironmentLayout({ const result = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" }, params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child-content">Hello!</div>, children: <div data-testid="child">Survey Editor Content</div>,
}); });
expect(redirect).toHaveBeenCalledWith("/auth/login"); render(result);
// No JSX is returned after redirect
expect(layoutElement).toBeUndefined(); expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
}); });
it("throws error if user does not exist in DB", async () => { test("throws an error when environment is not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
await expect( user: { id: "user1", email: "user1@example.com" } as TUser,
SurveyEditorEnvironmentLayout({ organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
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); vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect( await expect(
SurveyEditorEnvironmentLayout({ SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" }, params: Promise.resolve({ environmentId: "env1" }),
children: <div>Child</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("common.environment_not_found"); ).rejects.toThrow("common.environment_not_found");
}); });
it("renders environment layout if everything is valid", async () => { test("calls redirect when session is null", async () => {
// Provide all valid data vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); t: ((key: string) => key) as any,
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser); session: undefined as unknown as Session,
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true); user: undefined as unknown as TUser,
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization); organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
vi.mocked(getEnvironment).mockResolvedValueOnce({ });
id: "env-123", vi.mocked(redirect).mockImplementationOnce(() => {
name: "My Test Environment", throw new Error("Redirect called");
} 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 await expect(
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!"); SurveyEditorEnvironmentLayout({
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument(); params: Promise.resolve({ environmentId: "env1" }),
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument(); children: <div>Content</div>,
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument(); })
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument(); ).rejects.toThrow("Redirect called");
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123"); });
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
}); });
}); });
@@ -1,46 +1,24 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { getTranslate } from "@/tolgee/server";
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 { getEnvironment } from "@formbricks/lib/environment/service"; import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
const SurveyEditorEnvironmentLayout = async (props) => { const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
const t = await getTranslate(); const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
const session = await getServerSession(authOptions);
if (!session?.user) { if (!session) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
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 hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
@@ -48,23 +26,16 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
return ( return (
<ResponseFilterProvider> <EnvironmentIdBaseLayout
<PosthogIdentify environmentId={params.environmentId}
session={session} session={session}
user={user} user={user}
environmentId={params.environmentId} organization={organization}>
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<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> </EnvironmentIdBaseLayout>
); );
}; };
@@ -1,5 +1,5 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient"; import { FormbricksClient } from "./FormbricksClient";
@@ -9,14 +9,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("foo=bar"), 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. // Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({ vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true, formbricksEnabled: true,
@@ -34,17 +26,21 @@ vi.mock("@formbricks/js", () => ({
})); }));
describe("FormbricksClient", () => { describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => { test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup"); const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId"); const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail"); const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="user-123" email="test@example.com" />); render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details. // Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({ expect(mockSetup).toHaveBeenCalledWith({
@@ -64,7 +60,15 @@ describe("FormbricksClient", () => {
const mockSetEmail = vi.spyOn(formbricks, "setEmail"); const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="" email="test@example.com" />); render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details. // Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled(); expect(mockSetup).not.toHaveBeenCalled();
@@ -1,32 +1,44 @@
"use client"; "use client";
import { formbricksEnabled } from "@/app/lib/formbricks";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
export const FormbricksClient = ({ userId, email }: { userId: string; email: string }) => { interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
if (formbricksEnabled && userId) { if (formbricksEnabled && userId) {
formbricks.setup({ formbricks.setup({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "", environmentId: formbricksEnvironmentId ?? "",
appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "", appUrl: formbricksApiHost ?? "",
}); });
formbricks.setUserId(userId); formbricks.setUserId(userId);
formbricks.setEmail(email); formbricks.setEmail(email);
} }
}, [userId, email]); }, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
useEffect(() => { useEffect(() => {
if (formbricksEnabled) { if (formbricksEnabled) {
formbricks.registerRouteChange(); formbricks.registerRouteChange();
} }
}, [pathname, searchParams]); }, [pathname, searchParams, formbricksEnabled]);
return null; return null;
}; };
@@ -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();
@@ -263,7 +265,7 @@ export const MainNavigation = ({
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent" "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />
@@ -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"
@@ -1,10 +1,8 @@
// PosthogIdentify.test.tsx
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import React from "react"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { PosthogIdentify } from "./PosthogIdentify"; import { PosthogIdentify } from "./PosthogIdentify";
@@ -20,7 +18,7 @@ describe("PosthogIdentify", () => {
cleanup(); cleanup();
}); });
it("identifies the user and sets groups when isPosthogEnabled is true", () => { test("identifies the user and sets groups when isPosthogEnabled is true", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();
@@ -74,7 +72,7 @@ describe("PosthogIdentify", () => {
}); });
}); });
it("does nothing if isPosthogEnabled is false", () => { test("does nothing if isPosthogEnabled is false", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();
@@ -97,7 +95,7 @@ describe("PosthogIdentify", () => {
expect(mockGroup).not.toHaveBeenCalled(); expect(mockGroup).not.toHaveBeenCalled();
}); });
it("does nothing if session user is missing", () => { test("does nothing if session user is missing", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();
@@ -122,7 +120,7 @@ describe("PosthogIdentify", () => {
expect(mockGroup).not.toHaveBeenCalled(); expect(mockGroup).not.toHaveBeenCalled();
}); });
it("identifies user but does not group if environmentId/organizationId not provided", () => { test("identifies user but does not group if environmentId/organizationId not provided", () => {
const mockIdentify = vi.fn(); const mockIdentify = vi.fn();
const mockGroup = vi.fn(); const mockGroup = vi.fn();
@@ -25,6 +25,8 @@ export const TYPE_MAPPING = {
[TSurveyQuestionTypeEnum.Address]: ["rich_text"], [TSurveyQuestionTypeEnum.Address]: ["rich_text"],
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"], [TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"], [TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
[TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"],
[TSurveyQuestionTypeEnum.Ranking]: ["rich_text"],
}; };
export const UNSUPPORTED_TYPES_BY_NOTION = [ export const UNSUPPORTED_TYPES_BY_NOTION = [
@@ -1,250 +1,156 @@
import "@testing-library/jest-dom/vitest"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { act, cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth"; import { Session } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { redirect } from "next/navigation";
import React from "react"; import { afterEach, describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
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 { getProjectByEnvironmentId } from "@formbricks/lib/project/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 { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout"; import EnvLayout from "./layout";
// mock all the dependencies // Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
vi.mock("@formbricks/lib/constants", () => ({ EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
IS_FORMBRICKS_CLOUD: false, }));
POSTHOG_API_KEY: "mock-posthog-api-key", vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
POSTHOG_HOST: "mock-posthog-host", EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
IS_POSTHOG_CONFIGURED: true, <div data-testid="EnvironmentIdBaseLayout">
ENCRYPTION_KEY: "mock-encryption-key", {environmentId}
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", {children}
GITHUB_ID: "mock-github-id", </div>
GITHUB_SECRET: "test-githubID", ),
GOOGLE_CLIENT_ID: "test-google-client-id", }));
GOOGLE_CLIENT_SECRET: "test-google-client-secret", vi.mock("@/modules/ui/components/toaster-client", () => ({
AZUREAD_CLIENT_ID: "test-azuread-client-id", ToasterClient: () => <div data-testid="ToasterClient" />,
AZUREAD_CLIENT_SECRET: "test-azure", }));
AZUREAD_TENANT_ID: "test-azuread-tenant-id", vi.mock("../../components/FormbricksClient", () => ({
OIDC_DISPLAY_NAME: "test-oidc-display-name", FormbricksClient: ({ userId, email }: any) => (
OIDC_CLIENT_ID: "test-oidc-client-id", <div data-testid="FormbricksClient">
OIDC_ISSUER: "test-oidc-issuer", {userId}-{email}
OIDC_CLIENT_SECRET: "test-oidc-client-secret", </div>
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", ),
WEBAPP_URL: "test-webapp-url", }));
IS_PRODUCTION: false, vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
})); }));
vi.mock("@/tolgee/server", () => ({ // Mocks for dependencies
getTranslate: vi.fn(() => { vi.mock("@/modules/environments/lib/utils", () => ({
return (key: string) => { environmentIdLayoutChecks: vi.fn(),
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", () => ({ vi.mock("@formbricks/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(), getProjectByEnvironmentId: vi.fn(),
})); }));
vi.mock("@formbricks/lib/user/service", () => ({ vi.mock("@formbricks/lib/membership/service", () => ({
getUser: vi.fn(), getMembershipByUserIdOrganizationId: 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", () => { describe("EnvLayout", () => {
beforeEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
it("redirects to /auth/login if there is no session", async () => { test("renders successfully when all dependencies return valid data", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null); vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
// Since it's an async server component, call EnvLayout yourself: session: { user: { id: "user1" } } as Session,
const layoutElement = await EnvLayout({ user: { id: "user1", email: "user1@example.com" } as TUser,
params: Promise.resolve({ environmentId: "env-123" }), organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
children: <div data-testid="child-content">Hello!</div>,
}); });
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
// Because we have no session, we expect a redirect to "/auth/login" const result = await EnvLayout({
expect(redirect).toHaveBeenCalledWith("/auth/login"); params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// If your code calls redirect() early and returns no JSX, expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
// layoutElement might be undefined or null. expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(layoutElement).toBeUndefined(); expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
expect(screen.getByTestId("child")).toHaveTextContent("Content");
}); });
it("redirects to /auth/login if user does not exist in DB", async () => { test("throws error if project is not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
user: { id: "user-123" }, t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
}); });
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); vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
await expect( await expect(
EnvLayout({ EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env1" }),
children: <div>Child</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("project_not_found"); ).rejects.toThrow("common.project_not_found");
}); });
it("calls notFound if membership is missing", async () => { test("throws error if membership is not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
user: { id: "user-123" }, t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
}); });
vi.mocked(getUser).mockResolvedValueOnce({ vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
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); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect( await expect(
EnvLayout({ EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env1" }),
children: <div>Child</div>, children: <div>Content</div>,
}) })
).rejects.toThrow("membership_not_found"); ).rejects.toThrow("common.membership_not_found");
}); });
it("renders environment layout if everything is valid", async () => { test("calls redirect when session is null", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
user: { id: "user-123" }, t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
}); });
vi.mocked(getUser).mockResolvedValueOnce({ vi.mocked(redirect).mockImplementationOnce(() => {
id: "user-123", throw new Error("Redirect called");
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!"); await expect(
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument(); EnvLayout({
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument(); params: Promise.resolve({ environmentId: "env1" }),
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument(); children: <div>Content</div>,
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument(); })
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument(); ).rejects.toThrow("Redirect called");
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument(); });
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
}); });
}); });
@@ -1,20 +1,10 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"; import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
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 { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { FormbricksClient } from "../../components/FormbricksClient";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler"; import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
import { PosthogIdentify } from "./components/PosthogIdentify";
const EnvLayout = async (props: { const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>; params: Promise<{ environmentId: string }>;
@@ -24,27 +14,16 @@ const EnvLayout = async (props: {
const { children } = props; const { children } = props;
const t = await getTranslate(); const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
const session = await getServerSession(authOptions);
if (!session?.user) { if (!session) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const user = await getUser(session.user.id);
if (!user) { if (!user) {
return redirect(`/auth/login`); throw new Error(t("common.user_not_found"));
} }
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId); const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) { if (!project) {
throw new Error(t("common.project_not_found")); throw new Error(t("common.project_not_found"));
@@ -57,23 +36,16 @@ const EnvLayout = async (props: {
} }
return ( return (
<ResponseFilterProvider> <EnvironmentIdBaseLayout
<PosthogIdentify environmentId={params.environmentId}
session={session} session={session}
user={user} user={user}
environmentId={params.environmentId} organization={organization}>
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<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> </EnvironmentIdBaseLayout>
); );
}; };
@@ -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;
@@ -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;
@@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({
loading, loading,
}: OrganizationSettingsNavbarProps) => { }: OrganizationSettingsNavbarProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { isMember } = getAccessFlags(membershipRole); const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember; const isPricingDisabled = isMember;
const { t } = useTranslate(); const { t } = useTranslate();
@@ -54,6 +54,13 @@ 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"),
hidden: !isOwner,
},
]; ];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />; return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -6,7 +6,7 @@ import {
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import Page from "./page"; import Page from "./page";
@@ -33,12 +33,16 @@ 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-azure-llm-resource-name", AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key", AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id", AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name", AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key", AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id", AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
})); }));
vi.mock("@/tolgee/server", () => ({ vi.mock("@/tolgee/server", () => ({
@@ -80,7 +84,7 @@ describe("Page", () => {
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true); vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
}); });
it("renders the page with organization settings", async () => { test("renders the page with organization settings", async () => {
const props = { const props = {
params: Promise.resolve({ environmentId: "env-123" }), params: Promise.resolve({ environmentId: "env-123" }),
}; };
@@ -90,7 +94,7 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("renders if session user id empty", async () => { test("renders if session user id empty", async () => {
mockEnvironmentAuth.session.user.id = ""; mockEnvironmentAuth.session.user.id = "";
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth); vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
@@ -104,7 +108,7 @@ describe("Page", () => {
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it("handles getEnvironmentAuth error", async () => { test("handles getEnvironmentAuth error", async () => {
vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error")); vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error"));
const props = { const props = {
@@ -71,7 +71,11 @@ const getQuestionColumnsData = (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span> <span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
<span className="truncate">{getLocalizedValue(matrixRow, "default")}</span> <span className="truncate">
{getLocalizedValue(question.headline, "default") +
" - " +
getLocalizedValue(matrixRow, "default")}
</span>
</div> </div>
</div> </div>
); );
@@ -35,6 +35,16 @@ export const DateQuestionSummary = ({
); );
}; };
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
@@ -70,8 +80,8 @@ export const DateQuestionSummary = ({
</div> </div>
)} )}
</div> </div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> <div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
{formatDateWithOrdinal(new Date(response.value as string))} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} {timeSince(new Date(response.updatedAt).toISOString(), locale)}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react"; import { InboxIcon } from "lucide-react";
import type { JSX } from "react"; import type { JSX } from "react";
@@ -42,7 +43,7 @@ export const QuestionSummaryHeader = ({
}; };
return ( return (
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6"> <div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl"> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes( {formatTextWithSlashes(
@@ -69,6 +70,7 @@ export const QuestionSummaryHeader = ({
</div> </div>
)} )}
</div> </div>
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
</div> </div>
); );
}; };
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -85,7 +85,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
cleanup(); cleanup();
}); });
it("calls copySurveyLink and clipboard.writeText on success", async () => { test("calls copySurveyLink and clipboard.writeText on success", async () => {
render( render(
<SurveyAnalysisCTA <SurveyAnalysisCTA
survey={dummySurvey} survey={dummySurvey}
@@ -108,7 +108,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
}); });
}); });
it("shows error toast on failure", async () => { test("shows error toast on failure", async () => {
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail"))); refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
render( render(
<SurveyAnalysisCTA <SurveyAnalysisCTA
@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
value && handleDatePickerClose(); value && handleDatePickerClose();
}}> }}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none"> <DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3"> <div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex"> <div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span> <span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" /> <ArrowDownToLineIcon className="ml-2 h-4 w-4" />
@@ -416,14 +416,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onClick={() => { onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv"); handleDowndloadResponses(FilterDownload.FILTER, "csv");
}}> }}>
<p className="text-slate-700">{t("environments.surveys.summary.current_selection_csv")}</p> <p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx"); handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
}}> }}>
<p className="text-slate-700"> <p className="text-slate-700">
{t("environments.surveys.summary.current_selection_excel")} {t("environments.surveys.summary.filtered_responses_excel")}
</p> </p>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
+6 -3
View File
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { getUser } from "@formbricks/lib/user/service"; import { getUser } from "@formbricks/lib/user/service";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import AppLayout from "./layout"; import AppLayout from "./layout";
@@ -36,6 +36,9 @@ vi.mock("@formbricks/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host", POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key", POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
})); }));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({ vi.mock("@/app/(app)/components/FormbricksClient", () => ({
@@ -56,7 +59,7 @@ describe("(app) AppLayout", () => {
cleanup(); cleanup();
}); });
it("renders child content and all sub-components when user exists", async () => { test("renders child content and all sub-components when user exists", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
@@ -74,7 +77,7 @@ describe("(app) AppLayout", () => {
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument(); expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
}); });
it("skips FormbricksClient if no user is present", async () => { test("skips FormbricksClient if no user is present", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null); vi.mocked(getServerSession).mockResolvedValueOnce(null);
const element = await AppLayout({ const element = await AppLayout({
+23 -2
View File
@@ -1,18 +1,31 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient"; import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper"; 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 { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants"; import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
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 />
@@ -25,7 +38,15 @@ const AppLayout = async ({ children }) => {
</Suspense> </Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}> <PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<> <>
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null} {user ? (
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} /> <IntercomClientWrapper user={user} />
<ToasterClient /> <ToasterClient />
{children} {children}
+2 -2
View File
@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import AppLayout from "../(auth)/layout"; import AppLayout from "../(auth)/layout";
vi.mock("@formbricks/lib/constants", () => ({ vi.mock("@formbricks/lib/constants", () => ({
@@ -18,7 +18,7 @@ vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
})); }));
describe("(auth) AppLayout", () => { describe("(auth) AppLayout", () => {
it("renders the NoMobileOverlay and IntercomClient, plus children", async () => { test("renders the NoMobileOverlay and IntercomClient, plus children", async () => {
const appLayoutElement = await AppLayout({ const appLayoutElement = await AppLayout({
children: <div data-testid="child-content">Hello from children!</div>, children: <div data-testid="child-content">Hello from children!</div>,
}); });
@@ -392,6 +392,19 @@ const getValue = (colType: string, value: string | string[] | Date | number | Re
}, },
]; ];
} }
if (Array.isArray(value)) {
const content = value.join("\n");
return [
{
text: {
content:
content.length > NOTION_RICH_TEXT_LIMIT
? truncateText(content, NOTION_RICH_TEXT_LIMIT)
: content,
},
},
];
}
return [ return [
{ {
text: { text: {
@@ -12,9 +12,10 @@ type FollowUpResult = {
error?: string; error?: string;
}; };
const evaluateFollowUp = async ( export const evaluateFollowUp = async (
followUpId: string, followUpId: string,
followUpAction: TSurveyFollowUpAction, followUpAction: TSurveyFollowUpAction,
survey: TSurvey,
response: TResponse, response: TResponse,
organization: TOrganization organization: TOrganization
): Promise<void> => { ): Promise<void> => {
@@ -22,6 +23,25 @@ const evaluateFollowUp = async (
const { to, subject, body, replyTo } = properties; const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to]; const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl || ""; const logoUrl = organization.whitelabel?.logoUrl || "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
html: body,
subject,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return;
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) { if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`); throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
} }
@@ -31,7 +51,16 @@ const evaluateFollowUp = async (
const parsedResult = z.string().email().safeParse(toValueFromResponse); const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) { if (parsedResult.data) {
// send email to this email address // send email to this email address
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl); await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else { } else {
throw new Error(`Email address is not valid for followup: ${followUpId}`); throw new Error(`Email address is not valid for followup: ${followUpId}`);
} }
@@ -42,7 +71,16 @@ const evaluateFollowUp = async (
} }
const parsedResult = z.string().email().safeParse(emailAddress); const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) { if (parsedResult.data) {
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl); await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else { } else {
throw new Error(`Email address is not valid for followup: ${followUpId}`); throw new Error(`Email address is not valid for followup: ${followUpId}`);
} }
@@ -53,7 +91,7 @@ export const sendSurveyFollowUps = async (
survey: TSurvey, survey: TSurvey,
response: TResponse, response: TResponse,
organization: TOrganization organization: TOrganization
) => { ): Promise<FollowUpResult[]> => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => { const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp; const { trigger } = followUp;
@@ -70,7 +108,7 @@ export const sendSurveyFollowUps = async (
} }
} }
return evaluateFollowUp(followUp.id, followUp.action, response, organization) return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
.then(() => ({ .then(() => ({
followUpId: followUp.id, followUpId: followUp.id,
status: "success" as const, status: "success" as const,
@@ -92,4 +130,6 @@ export const sendSurveyFollowUps = async (
if (errors.length > 0) { if (errors.length > 0) {
logger.error(errors, "Follow-up processing errors"); logger.error(errors, "Follow-up processing errors");
} }
return followUpResults;
}; };
@@ -0,0 +1,267 @@
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyContactInfoQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
export const mockEndingId1 = "mpkt4n5krsv2ulqetle7b9e7";
export const mockEndingId2 = "ge0h63htnmgq6kwx1suh9cyi";
export const mockResponseEmailFollowUp: TSurvey["followUps"][number] = {
id: "cm9gpuazd0002192z67olbfdt",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up",
trigger: {
type: "response",
properties: null,
},
action: {
type: "send-email",
properties: {
to: "vjniuob08ggl8dewl0hwed41",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockEndingFollowUp: TSurvey["followUps"][number] = {
id: "j0g23cue6eih6xs5m0m4cj50",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up",
trigger: {
type: "endings",
properties: {
endingIds: [mockEndingId1],
},
},
action: {
type: "send-email",
properties: {
to: "vjniuob08ggl8dewl0hwed41",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockDirectEmailFollowUp: TSurvey["followUps"][number] = {
id: "yyc5sq1fqofrsyw4viuypeku",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up 1",
trigger: {
type: "response",
properties: null,
},
action: {
type: "send-email",
properties: {
to: "direct@email.com",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockFollowUps: TSurvey["followUps"] = [mockDirectEmailFollowUp, mockResponseEmailFollowUp];
export const mockSurvey: TSurvey = {
id: "cm9gptbhg0000192zceq9ayuc",
createdAt: new Date(),
updatedAt: new Date(),
name: "Start from scratch",
type: "link",
environmentId: "cm98djl8e000919hpzi6a80zp",
createdBy: "cm98dg3xm000019hpubj39vfi",
status: "inProgress",
welcomeCard: {
html: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,
headline: {
default: "Welcome!",
},
buttonLabel: {
default: "Next",
},
timeToFinish: false,
showResponseCount: false,
},
questions: [
{
id: "vjniuob08ggl8dewl0hwed41",
type: "openText" as TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "What would you like to know?",
},
required: true,
charLimit: {},
inputType: "email",
longAnswer: false,
buttonLabel: {
default: "Next",
},
placeholder: {
default: "example@email.com",
},
},
],
endings: [
{
id: "gt1yoaeb5a3istszxqbl08mk",
type: "endScreen",
headline: {
default: "Thank you!",
},
subheader: {
default: "We appreciate your feedback.",
},
buttonLink: "https://formbricks.com",
buttonLabel: {
default: "Create your own Survey",
},
},
],
hiddenFields: {
enabled: true,
fieldIds: [],
},
variables: [],
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
autoClose: null,
runOnDate: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
projectOverwrites: null,
styling: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,
isEncrypted: true,
},
pin: null,
resultShareKey: null,
showLanguageSwitch: null,
languages: [],
triggers: [],
segment: null,
followUps: mockFollowUps,
};
export const mockContactQuestion: TSurveyContactInfoQuestion = {
id: "zyoobxyolyqj17bt1i4ofr37",
type: TSurveyQuestionTypeEnum.ContactInfo,
email: {
show: true,
required: true,
placeholder: {
default: "Email",
},
},
phone: {
show: true,
required: true,
placeholder: {
default: "Phone",
},
},
company: {
show: true,
required: true,
placeholder: {
default: "Company",
},
},
headline: {
default: "Contact Question",
},
lastName: {
show: true,
required: true,
placeholder: {
default: "Last Name",
},
},
required: true,
firstName: {
show: true,
required: true,
placeholder: {
default: "First Name",
},
},
buttonLabel: {
default: "Next",
},
backButtonLabel: {
default: "Back",
},
};
export const mockContactEmailFollowUp: TSurvey["followUps"][number] = {
...mockResponseEmailFollowUp,
action: {
...mockResponseEmailFollowUp.action,
properties: {
...mockResponseEmailFollowUp.action.properties,
to: mockContactQuestion.id,
},
},
};
export const mockSurveyWithContactQuestion: TSurvey = {
...mockSurvey,
questions: [mockContactQuestion],
followUps: [mockContactEmailFollowUp],
};
export const mockResponse: TResponse = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
variables: {},
language: "en",
data: {
["vjniuob08ggl8dewl0hwed41"]: "test@example.com",
},
contact: null,
contactAttributes: {},
meta: {},
finished: true,
notes: [],
singleUseId: null,
tags: [],
displayId: null,
};
export const mockResponseWithContactQuestion: TResponse = {
...mockResponse,
data: {
zyoobxyolyqj17bt1i4ofr37: ["test", "user1", "test@user1.com", "99999999999", "sampleCompany"],
},
};
@@ -0,0 +1,235 @@
import {
mockContactEmailFollowUp,
mockDirectEmailFollowUp,
mockEndingFollowUp,
mockEndingId2,
mockResponse,
mockResponseEmailFollowUp,
mockResponseWithContactQuestion,
mockSurvey,
mockSurveyWithContactQuestion,
} from "@/app/api/(internal)/pipeline/lib/tests/__mocks__/survey-follow-up.mock";
import { sendFollowUpEmail } from "@/modules/email";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { evaluateFollowUp, sendSurveyFollowUps } from "../survey-follow-up";
// Mock dependencies
vi.mock("@/modules/email", () => ({
sendFollowUpEmail: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Survey Follow Up", () => {
const mockOrganization: Partial<TOrganization> = {
id: "org1",
name: "Test Org",
whitelabel: {
logoUrl: "https://example.com/logo.png",
},
};
describe("evaluateFollowUp", () => {
test("sends email when to is a direct email address", async () => {
const followUpId = mockDirectEmailFollowUp.id;
const followUpAction = mockDirectEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
mockResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockDirectEmailFollowUp.action.properties.body,
subject: mockDirectEmailFollowUp.action.properties.subject,
to: mockDirectEmailFollowUp.action.properties.to,
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockResponseEmailFollowUp.action.properties.body,
subject: mockResponseEmailFollowUp.action.properties.subject,
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email in array", async () => {
const followUpId = mockContactEmailFollowUp.id;
const followUpAction = mockContactEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurveyWithContactQuestion,
mockResponseWithContactQuestion,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockContactEmailFollowUp.action.properties.body,
subject: mockContactEmailFollowUp.action.properties.subject,
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
survey: mockSurveyWithContactQuestion,
response: mockResponseWithContactQuestion,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("throws error when to value is not found in response data", async () => {
const followUpId = "followup1";
const followUpAction = {
...mockSurvey.followUps![0].action,
properties: {
...mockSurvey.followUps![0].action.properties,
to: "nonExistentField",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
});
test("throws error when email address is invalid", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
const invalidResponse = {
...mockResponse,
data: {
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
invalidResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
});
});
describe("sendSurveyFollowUps", () => {
test("skips follow-up when ending Id doesn't match", async () => {
const responseWithDifferentEnding = {
...mockResponse,
endingId: mockEndingId2,
};
const mockSurveyWithEndingFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockEndingFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithEndingFollowUp,
responseWithDifferentEnding as TResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockEndingFollowUp.id,
status: "skipped",
},
]);
expect(sendFollowUpEmail).not.toHaveBeenCalled();
});
test("processes follow-ups and log errors", async () => {
const error = new Error("Test error");
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
const mockSurveyWithFollowUps: TSurvey = {
...mockSurvey,
followUps: [mockResponseEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUps,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockResponseEmailFollowUp.id,
status: "error",
error: "Test error",
},
]);
expect(logger.error).toHaveBeenCalledWith(
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
"Follow-up processing errors"
);
});
test("successfully processes follow-ups", async () => {
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
const mockSurveyWithFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockDirectEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUp,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockDirectEmailFollowUp.id,
status: "success",
},
]);
expect(logger.error).not.toHaveBeenCalled();
});
});
});
@@ -20,6 +20,7 @@ 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 { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { handleIntegrations } from "./lib/handleIntegrations"; import { handleIntegrations } from "./lib/handleIntegrations";
export const POST = async (request: Request) => { export const POST = async (request: Request) => {
@@ -50,7 +51,7 @@ export const POST = async (request: Request) => {
const organization = await getOrganizationByEnvironmentId(environmentId); const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) { if (!organization) {
throw new Error("Organization not found"); throw new ResourceNotFoundError("Organization", "Organization not found");
} }
// Fetch webhooks // Fetch webhooks
+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, test, 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", () => {
test("returns 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) },
});
});
test("returns 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",
},
];
test("returns 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);
});
test("handles 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);
});
test("handles 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);
});
test("returns false for non-existent environment", () => {
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
});
});
describe("authenticateRequest", () => {
test("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",
});
});
test("returns null when no API key is provided", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
test("returns 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 => {
-49
View File
@@ -1,49 +0,0 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { getHash } from "@formbricks/lib/crypto";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
const hashedKey = getHash(apiKey);
return cache(
async () => {
validateInputs([apiKey, ZString]);
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
select: {
environmentId: true,
},
});
if (!apiKeyData) {
throw new ResourceNotFoundError("apiKey", apiKey);
}
return apiKeyData.environmentId;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});
@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service"; import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
@@ -8,15 +9,20 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
const fetchAndAuthorizeActionClass = async ( const fetchAndAuthorizeActionClass = async (
authentication: TAuthenticationApiKey, authentication: TAuthenticationApiKey,
actionClassId: string actionClassId: string,
method: "GET" | "POST" | "PUT" | "DELETE"
): Promise<TActionClass | null> => { ): Promise<TActionClass | null> => {
// Get the action class
const actionClass = await getActionClass(actionClassId); const actionClass = await getActionClass(actionClassId);
if (!actionClass) { if (!actionClass) {
return null; return null;
} }
if (actionClass.environmentId !== authentication.environmentId) {
// Check if API key has permission to access this environment with appropriate permissions
if (!hasPermission(authentication.environmentPermissions, actionClass.environmentId, method)) {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
return actionClass; return actionClass;
}; };
@@ -28,7 +34,7 @@ export const GET = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId); const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) { if (actionClass) {
return responses.successResponse(actionClass); return responses.successResponse(actionClass);
} }
@@ -46,7 +52,7 @@ export const PUT = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId); const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) { if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId); return responses.notFoundResponse("Action Class", params.actionClassId);
} }
@@ -88,7 +94,7 @@ export const DELETE = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId); const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) { if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId); return responses.notFoundResponse("Action Class", params.actionClassId);
} }
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./action-classes";
// Mock the prisma client
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
},
},
}));
describe("getActionClasses", () => {
const mockEnvironmentIds = ["env1", "env2"];
const mockActionClasses = [
{
id: "action1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 1",
description: "Test Description 1",
type: "click",
key: "test-key-1",
noCodeConfig: {},
environmentId: "env1",
},
{
id: "action2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 2",
description: "Test Description 2",
type: "pageview",
key: "test-key-2",
noCodeConfig: {},
environmentId: "env2",
},
];
beforeEach(() => {
vi.clearAllMocks();
});
test("successfully fetches action classes for given environment IDs", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
const result = await getActionClasses(mockEnvironmentIds);
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: {
environmentId: { in: mockEnvironmentIds },
},
select: expect.any(Object),
orderBy: {
createdAt: "asc",
},
});
});
test("throws DatabaseError when prisma query fails", async () => {
// Mock the prisma findMany to throw an error
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error"));
await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
});
test("handles empty environment IDs array", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
const result = await getActionClasses([]);
expect(result).toEqual([]);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: {
environmentId: { in: [] },
},
select: expect.any(Object),
orderBy: {
createdAt: "asc",
},
});
});
});
@@ -0,0 +1,51 @@
"use server";
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
const selectActionClass = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
key: true,
noCodeConfig: true,
environmentId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(
async (environmentIds: string[]): Promise<TActionClass[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()]);
try {
return await prisma.actionClass.findMany({
where: {
environmentId: { in: environmentIds },
},
select: selectActionClass,
orderBy: {
createdAt: "asc",
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
}
},
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`),
{
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)),
}
)()
);
@@ -1,16 +1,24 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./lib/action-classes";
export const GET = async (request: Request) => { export const GET = async (request: Request) => {
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const actionClasses: TActionClass[] = await getActionClasses(authentication.environmentId!);
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return responses.successResponse(actionClasses); return responses.successResponse(actionClasses);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -35,6 +43,12 @@ export const POST = async (request: Request): Promise<Response> => {
const inputValidation = ZActionClassInput.safeParse(actionClassInput); const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) { if (!inputValidation.success) {
return responses.badRequestResponse( return responses.badRequestResponse(
"Fields are missing or incorrectly formatted", "Fields are missing or incorrectly formatted",
@@ -43,10 +57,7 @@ export const POST = async (request: Request): Promise<Response> => {
); );
} }
const actionClass: TActionClass = await createActionClass( const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
authentication.environmentId!,
inputValidation.data
);
return responses.successResponse(actionClass); return responses.successResponse(actionClass);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
+36 -9
View File
@@ -12,29 +12,56 @@ export const GET = async () => {
hashedKey: hashApiKey(apiKey), hashedKey: hashApiKey(apiKey),
}, },
select: { select: {
environment: { apiKeyEnvironments: {
select: { select: {
id: true, environment: {
createdAt: true,
updatedAt: true,
type: true,
project: {
select: { select: {
id: true, id: true,
name: true, type: true,
createdAt: true,
updatedAt: true,
projectId: true,
widgetSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
}, },
}, },
appSetupCompleted: true, permission: true,
}, },
}, },
}, },
}); });
if (!apiKeyData) { if (!apiKeyData) {
return new Response("Not authenticated", { return new Response("Not authenticated", {
status: 401, status: 401,
}); });
} }
return Response.json(apiKeyData.environment);
if (
apiKeyData.apiKeyEnvironments.length === 1 &&
apiKeyData.apiKeyEnvironments[0].permission === "manage"
) {
return Response.json({
id: apiKeyData.apiKeyEnvironments[0].environment.id,
type: apiKeyData.apiKeyEnvironments[0].environment.type,
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
widgetSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.widgetSetupCompleted,
project: {
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
},
});
} else {
return new Response("You can't use this method with this API key", {
status: 400,
});
}
} else { } else {
const sessionUser = await getSessionUser(); const sessionUser = await getSessionUser();
if (!sessionUser) { if (!sessionUser) {
@@ -1,32 +1,33 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service"; import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses"; import { ZResponseUpdateInput } from "@formbricks/types/responses";
const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise<TResponse> => { async function fetchAndAuthorizeResponse(
responseId: string,
authentication: any,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
const response = await getResponse(responseId); const response = await getResponse(responseId);
if (!response || !(await canUserAccessResponse(authentication, response))) { if (!response) {
throw new Error("Unauthorized"); return { error: responses.notFoundResponse("Response", responseId) };
} }
return response;
};
const canUserAccessResponse = async (authentication: any, response: TResponse): Promise<boolean> => {
const survey = await getSurvey(response.surveyId); const survey = await getSurvey(response.surveyId);
if (!survey) return false; if (!survey) {
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
if (authentication.type === "session") {
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
} else if (authentication.type === "apiKey") {
return survey.environmentId === authentication.environmentId;
} else {
throw Error("Unknown authentication type");
} }
};
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}
return { response };
}
export const GET = async ( export const GET = async (
request: Request, request: Request,
@@ -36,11 +37,11 @@ export const GET = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const response = await fetchAndValidateResponse(authentication, params.responseId);
if (response) { const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
return responses.successResponse(response); if (result.error) return result.error;
}
return responses.notFoundResponse("Response", params.responseId); return responses.successResponse(result.response);
} catch (error) { } catch (error) {
return handleErrorResponse(error); return handleErrorResponse(error);
} }
@@ -54,10 +55,10 @@ export const DELETE = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const response = await fetchAndValidateResponse(authentication, params.responseId);
if (!response) { const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
return responses.notFoundResponse("Response", params.responseId); if (result.error) return result.error;
}
const deletedResponse = await deleteResponse(params.responseId); const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse); return responses.successResponse(deletedResponse);
} catch (error) { } catch (error) {
@@ -73,7 +74,10 @@ export const PUT = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
await fetchAndValidateResponse(authentication, params.responseId);
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) return result.error;
let responseUpdate; let responseUpdate;
try { try {
responseUpdate = await request.json(); responseUpdate = await request.json();
@@ -1,6 +1,8 @@
import "server-only"; import "server-only";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
@@ -8,11 +10,13 @@ import {
} from "@formbricks/lib/organization/service"; } from "@formbricks/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { responseCache } from "@formbricks/lib/response/cache"; import { responseCache } from "@formbricks/lib/response/cache";
import { getResponseContact } from "@formbricks/lib/response/service";
import { calculateTtcTotal } from "@formbricks/lib/response/utils"; import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry"; import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate"; import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -25,6 +29,7 @@ export const responseSelection = {
updatedAt: true, updatedAt: true,
surveyId: true, surveyId: true,
finished: true, finished: true,
endingId: true,
data: true, data: true,
meta: true, meta: true,
ttc: true, ttc: true,
@@ -193,3 +198,53 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
throw error; throw error;
} }
}; };
export const getResponsesByEnvironmentIds = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const responses = await prisma.response.findMany({
where: {
survey: {
environmentId: { in: environmentIds },
},
},
select: responseSelection,
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
environmentIds.map(
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
),
{
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
}
)()
);
@@ -1,13 +1,14 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service"; import { getResponses } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { createResponse } from "./lib/response"; import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
export const GET = async (request: NextRequest) => { export const GET = async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
@@ -18,14 +19,26 @@ export const GET = async (request: NextRequest) => {
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
let environmentResponses: TResponse[] = []; let allResponses: TResponse[] = [];
if (surveyId) { if (surveyId) {
environmentResponses = await getResponses(surveyId, limit, offset); const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else { } else {
environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset); const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
} }
return responses.successResponse(environmentResponses); return responses.successResponse(allResponses);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message); return responses.badRequestResponse(error.message);
@@ -39,8 +52,6 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const environmentId = authentication.environmentId;
let jsonInput; let jsonInput;
try { try {
@@ -50,9 +61,6 @@ export const POST = async (request: Request): Promise<Response> => {
return responses.badRequestResponse("Malformed JSON input, please check your request body"); return responses.badRequestResponse("Malformed JSON input, please check your request body");
} }
// add environmentId to response
jsonInput.environmentId = environmentId;
const inputValidation = ZResponseInput.safeParse(jsonInput); const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) { if (!inputValidation.success) {
@@ -65,6 +73,12 @@ export const POST = async (request: Request): Promise<Response> => {
const responseInput = inputValidation.data; const responseInput = inputValidation.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// get and check survey // get and check survey
const survey = await getSurvey(responseInput.surveyId); const survey = await getSurvey(responseInput.surveyId);
if (!survey) { if (!survey) {
@@ -3,21 +3,28 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types"; import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise<TSurvey | null> => { const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
requiredPermission: "GET" | "PUT" | "DELETE"
) => {
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
if (!survey) { if (!survey) {
return null; return { error: responses.notFoundResponse("Survey", surveyId) };
} }
if (survey.environmentId !== authentication.environmentId) { if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
throw new Error("Unauthorized"); return { error: responses.unauthorizedResponse() };
} }
return survey;
return { survey };
}; };
export const GET = async ( export const GET = async (
@@ -28,11 +35,9 @@ export const GET = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId); const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (survey) { if (result.error) return result.error;
return responses.successResponse(survey); return responses.successResponse(result.survey);
}
return responses.notFoundResponse("Survey", params.surveyId);
} catch (error) { } catch (error) {
return handleErrorResponse(error); return handleErrorResponse(error);
} }
@@ -46,10 +51,8 @@ export const DELETE = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId); const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (!survey) { if (result.error) return result.error;
return responses.notFoundResponse("Survey", params.surveyId);
}
const deletedSurvey = await deleteSurvey(params.surveyId); const deletedSurvey = await deleteSurvey(params.surveyId);
return responses.successResponse(deletedSurvey); return responses.successResponse(deletedSurvey);
} catch (error) { } catch (error) {
@@ -65,13 +68,10 @@ export const PUT = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) return result.error;
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId); const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) { if (!organization) {
return responses.notFoundResponse("Organization", null); return responses.notFoundResponse("Organization", null);
} }
@@ -85,7 +85,7 @@ export const PUT = async (
} }
const inputValidation = ZSurveyUpdateInput.safeParse({ const inputValidation = ZSurveyUpdateInput.safeParse({
...survey, ...result.survey,
...surveyUpdate, ...surveyUpdate,
}); });
@@ -1,5 +1,6 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
@@ -17,8 +18,8 @@ export const GET = async (
if (!survey) { if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId); return responses.notFoundResponse("Survey", params.surveyId);
} }
if (survey.environmentId !== authentication.environmentId) { if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
throw new Error("Unauthorized"); return responses.unauthorizedResponse();
} }
if (!survey.singleUse || !survey.singleUse.enabled) { if (!survey.singleUse || !survey.singleUse.enabled) {
@@ -0,0 +1,48 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId: { in: environmentIds },
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
take: limit,
skip: offset,
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting surveys");
throw new DatabaseError(error.message);
}
throw error;
}
},
environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`),
{
tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)),
}
)()
);
+23 -13
View File
@@ -2,12 +2,14 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service"; import { createSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types"; import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { getSurveys } from "./lib/surveys";
export const GET = async (request: Request) => { export const GET = async (request: Request) => {
try { try {
@@ -18,7 +20,11 @@ export const GET = async (request: Request) => {
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined; const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined; const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const surveys = await getSurveys(authentication.environmentId!, limit, offset); const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
return responses.successResponse(surveys); return responses.successResponse(surveys);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -33,11 +39,6 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyInput; let surveyInput;
try { try {
surveyInput = await request.json(); surveyInput = await request.json();
@@ -45,8 +46,7 @@ export const POST = async (request: Request): Promise<Response> => {
logger.error({ error, url: request.url }, "Error parsing JSON"); logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body"); return responses.badRequestResponse("Malformed JSON input, please check your request body");
} }
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
if (!inputValidation.success) { if (!inputValidation.success) {
return responses.badRequestResponse( return responses.badRequestResponse(
@@ -56,8 +56,18 @@ export const POST = async (request: Request): Promise<Response> => {
); );
} }
const environmentId = authentication.environmentId; const environmentId = inputValidation.data.environmentId;
const surveyData = { ...inputValidation.data, environmentId: undefined };
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
const surveyData = { ...inputValidation.data, environmentId };
if (surveyData.followUps?.length) { if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
@@ -73,7 +83,7 @@ export const POST = async (request: Request): Promise<Response> => {
} }
} }
const survey = await createSurvey(environmentId, surveyData); const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
return responses.successResponse(survey); return responses.successResponse(survey);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -1,18 +1,19 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key"; import { authenticateRequest } from "@/app/api/v1/auth";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook"; import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => { export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params; const params = await props.params;
const headersList = await headers(); const headersList = await headers();
const apiKey = headersList.get("x-api-key"); const apiKey = headersList.get("x-api-key");
if (!apiKey) { if (!apiKey) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const environmentId = await getEnvironmentIdFromApiKey(apiKey); const authentication = await authenticateRequest(request);
if (!environmentId) { if (!authentication) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
@@ -21,7 +22,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
if (!webhook) { if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId); return responses.notFoundResponse("Webhook", params.webhookId);
} }
if (webhook.environmentId !== environmentId) { if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
return responses.unauthorizedResponse(); return responses.unauthorizedResponse();
} }
return responses.successResponse(webhook); return responses.successResponse(webhook);
@@ -34,8 +35,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!apiKey) { if (!apiKey) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const environmentId = await getEnvironmentIdFromApiKey(apiKey); const authentication = await authenticateRequest(request);
if (!environmentId) { if (!authentication) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
@@ -44,7 +45,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!webhook) { if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId); return responses.notFoundResponse("Webhook", params.webhookId);
} }
if (webhook.environmentId !== environmentId) { if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
return responses.unauthorizedResponse(); return responses.unauthorizedResponse();
} }
+15 -10
View File
@@ -8,17 +8,20 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => { export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]); validateInputs([webhookInput, ZWebhookInput]);
try { try {
const createdWebhook = await prisma.webhook.create({ const createdWebhook = await prisma.webhook.create({
data: { data: {
...webhookInput, url: webhookInput.url,
name: webhookInput.name,
source: webhookInput.source,
surveyIds: webhookInput.surveyIds || [], surveyIds: webhookInput.surveyIds || [],
triggers: webhookInput.triggers || [],
environment: { environment: {
connect: { connect: {
id: environmentId, id: webhookInput.environmentId,
}, },
}, },
}, },
@@ -37,22 +40,24 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
} }
if (!(error instanceof InvalidInputError)) { if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`); throw new DatabaseError(
`Database error when creating webhook for environment ${webhookInput.environmentId}`
);
} }
throw error; throw error;
} }
}; };
export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> => export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
cache( cache(
async () => { async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]); validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
try { try {
const webhooks = await prisma.webhook.findMany({ const webhooks = await prisma.webhook.findMany({
where: { where: {
environmentId: environmentId, environmentId: { in: environmentIds },
}, },
take: page ? ITEMS_PER_PAGE : undefined, take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
@@ -66,8 +71,8 @@ export const getWebhooks = (environmentId: string, page?: number): Promise<Webho
throw error; throw error;
} }
}, },
[`getWebhooks-${environmentId}-${page}`], environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
{ {
tags: [webhookCache.tag.byEnvironmentId(environmentId)], tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
} }
)(); )();
+25 -24
View File
@@ -1,42 +1,33 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key"; import { authenticateRequest } from "@/app/api/v1/auth";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook"; import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { headers } from "next/headers"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const GET = async () => { export const GET = async (request: Request) => {
const headersList = await headers(); const authentication = await authenticateRequest(request);
const apiKey = headersList.get("x-api-key"); if (!authentication) {
if (!apiKey) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
// get webhooks from database
try { try {
const webhooks = await getWebhooks(environmentId); const environmentIds = authentication.environmentPermissions.map(
return Response.json({ data: webhooks }); (permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
return responses.successResponse(webhooks);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message); return responses.internalServerErrorResponse(error.message);
} }
return responses.internalServerErrorResponse(error.message); throw error;
} }
}; };
export const POST = async (request: Request) => { export const POST = async (request: Request) => {
const headersList = await headers(); const authentication = await authenticateRequest(request);
const apiKey = headersList.get("x-api-key"); if (!authentication) {
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const webhookInput = await request.json(); const webhookInput = await request.json();
@@ -50,9 +41,19 @@ export const POST = async (request: Request) => {
); );
} }
const environmentId = inputValidation.data.environmentId;
if (!environmentId) {
return responses.badRequestResponse("Environment ID is required");
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// add webhook to database // add webhook to database
try { try {
const webhook = await createWebhook(environmentId, inputValidation.data); const webhook = await createWebhook(inputValidation.data);
return responses.successResponse(webhook); return responses.successResponse(webhook);
} catch (error) { } catch (error) {
if (error instanceof InvalidInputError) { if (error instanceof InvalidInputError) {
@@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({
surveyIds: true, surveyIds: true,
triggers: true, triggers: true,
url: true, url: true,
environmentId: true,
}); });
export type TWebhookInput = z.infer<typeof ZWebhookInput>; export type TWebhookInput = z.infer<typeof ZWebhookInput>;
@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/roles/route";
export { GET };
@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
export { GET };
+3
View File
@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/me/route";
export { GET };
@@ -0,0 +1,3 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };
@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
export { GET, PUT, DELETE };
@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
export { GET, POST };
@@ -0,0 +1,3 @@
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
export { GET, POST, PATCH };
+3
View File
@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/roles/route";
export { GET };
@@ -1,7 +1,7 @@
import Intercom from "@intercom/messenger-js-sdk"; import Intercom from "@intercom/messenger-js-sdk";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { IntercomClient } from "./IntercomClient"; import { IntercomClient } from "./IntercomClient";
@@ -26,7 +26,7 @@ describe("IntercomClient", () => {
global.window.Intercom = originalWindowIntercom; global.window.Intercom = originalWindowIntercom;
}); });
it("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => { test("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => {
const testUser = { const testUser = {
id: "test-id", id: "test-id",
name: "Test User", name: "Test User",
@@ -55,7 +55,7 @@ describe("IntercomClient", () => {
}); });
}); });
it("calls Intercom with user data without createdAt", () => { test("calls Intercom with user data without createdAt", () => {
const testUser = { const testUser = {
id: "test-id", id: "test-id",
name: "Test User", name: "Test User",
@@ -83,7 +83,7 @@ describe("IntercomClient", () => {
}); });
}); });
it("calls Intercom with minimal params if user is not provided", () => { test("calls Intercom with minimal params if user is not provided", () => {
render( render(
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" /> <IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
); );
@@ -94,7 +94,7 @@ describe("IntercomClient", () => {
}); });
}); });
it("does not call Intercom if isIntercomConfigured is false", () => { test("does not call Intercom if isIntercomConfigured is false", () => {
render( render(
<IntercomClient <IntercomClient
isIntercomConfigured={false} isIntercomConfigured={false}
@@ -106,7 +106,7 @@ describe("IntercomClient", () => {
expect(Intercom).not.toHaveBeenCalled(); expect(Intercom).not.toHaveBeenCalled();
}); });
it("shuts down Intercom on unmount", () => { test("shuts down Intercom on unmount", () => {
const { unmount } = render( const { unmount } = render(
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" /> <IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
); );
@@ -120,7 +120,7 @@ describe("IntercomClient", () => {
expect(mockWindowIntercom).toHaveBeenCalledWith("shutdown"); expect(mockWindowIntercom).toHaveBeenCalledWith("shutdown");
}); });
it("logs an error if Intercom initialization fails", () => { test("logs an error if Intercom initialization fails", () => {
// Spy on console.error // Spy on console.error
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -141,7 +141,7 @@ describe("IntercomClient", () => {
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
}); });
it("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => { test("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render( render(
@@ -159,7 +159,7 @@ describe("IntercomClient", () => {
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
}); });
it("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => { test("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const testUser = { const testUser = {
id: "test-id", id: "test-id",
@@ -1,5 +1,5 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { IntercomClientWrapper } from "./IntercomClientWrapper"; import { IntercomClientWrapper } from "./IntercomClientWrapper";
@@ -31,7 +31,7 @@ describe("IntercomClientWrapper", () => {
cleanup(); cleanup();
}); });
it("renders IntercomClient with computed user hash when user is provided", () => { test("renders IntercomClient with computed user hash when user is provided", () => {
const testUser = { id: "user-123", name: "Test User", email: "test@example.com" } as TUser; const testUser = { id: "user-123", name: "Test User", email: "test@example.com" } as TUser;
render(<IntercomClientWrapper user={testUser} />); render(<IntercomClientWrapper user={testUser} />);
@@ -48,7 +48,7 @@ describe("IntercomClientWrapper", () => {
expect(props.user).toEqual(testUser); expect(props.user).toEqual(testUser);
}); });
it("renders IntercomClient without computing a hash when no user is provided", () => { test("renders IntercomClient without computing a hash when no user is provided", () => {
render(<IntercomClientWrapper user={null} />); render(<IntercomClientWrapper user={null} />);
const intercomClientEl = screen.getByTestId("mock-intercom-client"); const intercomClientEl = screen.getByTestId("mock-intercom-client");
+2 -10
View File
@@ -3,7 +3,7 @@ import { getTolgee } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { TolgeeInstance } from "@tolgee/react"; import { TolgeeInstance } from "@tolgee/react";
import React from "react"; import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import RootLayout from "./layout"; import RootLayout from "./layout";
// Mock dependencies for the layout // Mock dependencies for the layout
@@ -40,10 +40,6 @@ vi.mock("@/tolgee/server", () => ({
getTolgee: vi.fn(), getTolgee: vi.fn(),
})); }));
vi.mock("@vercel/speed-insights/next", () => ({
SpeedInsights: () => <div data-testid="speed-insights">SpeedInsights</div>,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({ vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => ( PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
<div data-testid="ph-provider"> <div data-testid="ph-provider">
@@ -85,7 +81,7 @@ describe("RootLayout", () => {
process.env.VERCEL = "1"; process.env.VERCEL = "1";
}); });
it("renders the layout with the correct structure and providers", async () => { test("renders the layout with the correct structure and providers", async () => {
const fakeLocale = "en-US"; const fakeLocale = "en-US";
// Mock getLocale to resolve to a fake locale // Mock getLocale to resolve to a fake locale
vi.mocked(getLocale).mockResolvedValue(fakeLocale); vi.mocked(getLocale).mockResolvedValue(fakeLocale);
@@ -101,10 +97,6 @@ describe("RootLayout", () => {
const element = await RootLayout({ children }); const element = await RootLayout({ children });
render(element); render(element);
// log env vercel
console.log("vercel", process.env.VERCEL);
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument(); expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument(); expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
expect(screen.getByTestId("child")).toHaveTextContent("Child Content"); expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
-2
View File
@@ -3,7 +3,6 @@ import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language"; import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server"; import { getTolgee } from "@/tolgee/server";
import { TolgeeStaticData } from "@tolgee/react"; import { TolgeeStaticData } from "@tolgee/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Metadata } from "next"; import { Metadata } from "next";
import React from "react"; import React from "react";
import { SENTRY_DSN } from "@formbricks/lib/constants"; import { SENTRY_DSN } from "@formbricks/lib/constants";
@@ -26,7 +25,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return ( return (
<html lang={locale} translate="no"> <html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out"> <body className="flex h-dvh flex-col transition-all ease-in-out">
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
<SentryProvider sentryDsn={SENTRY_DSN}> <SentryProvider sentryDsn={SENTRY_DSN}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}> <TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children} {children}
-4
View File
@@ -1,10 +1,6 @@
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage"; import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
export const formbricksEnabled =
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
export const formbricksLogout = async () => { export const formbricksLogout = async () => {
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS); const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
localStorage.clear(); localStorage.clear();
+7 -25
View File
@@ -1,5 +1,5 @@
import cuid2 from "@paralleldrive/cuid2"; import cuid2 from "@paralleldrive/cuid2";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@formbricks/lib/crypto"; import * as crypto from "@formbricks/lib/crypto";
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys"; import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
@@ -13,7 +13,6 @@ vi.mock("@formbricks/lib/crypto", () => ({
// Mock constants // Mock constants
vi.mock("@formbricks/lib/constants", () => ({ vi.mock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key", ENCRYPTION_KEY: "test-encryption-key",
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
})); }));
// Mock cuid2 // Mock cuid2
@@ -45,21 +44,21 @@ describe("generateSurveySingleUseId", () => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
it("returns unencrypted cuid when isEncrypted is false", () => { test("returns unencrypted cuid when isEncrypted is false", () => {
const result = generateSurveySingleUseId(false); const result = generateSurveySingleUseId(false);
expect(result).toBe(mockCuid); expect(result).toBe(mockCuid);
expect(crypto.symmetricEncrypt).not.toHaveBeenCalled(); expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
}); });
it("returns encrypted cuid when isEncrypted is true", () => { test("returns encrypted cuid when isEncrypted is true", () => {
const result = generateSurveySingleUseId(true); const result = generateSurveySingleUseId(true);
expect(result).toBe(mockEncryptedCuid); expect(result).toBe(mockEncryptedCuid);
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key"); expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key");
}); });
it("returns undefined when cuid is not valid", () => { test("returns undefined when cuid is not valid", () => {
vi.mocked(cuid2.isCuid).mockReturnValue(false); vi.mocked(cuid2.isCuid).mockReturnValue(false);
const result = validateSurveySingleUseId(mockEncryptedCuid); const result = validateSurveySingleUseId(mockEncryptedCuid);
@@ -67,7 +66,7 @@ describe("generateSurveySingleUseId", () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it("returns undefined when decryption fails", () => { test("returns undefined when decryption fails", () => {
vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => { vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => {
throw new Error("Decryption failed"); throw new Error("Decryption failed");
}); });
@@ -77,11 +76,10 @@ describe("generateSurveySingleUseId", () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
it("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => { test("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => {
// Temporarily mock ENCRYPTION_KEY as undefined // Temporarily mock ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({ vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined, ENCRYPTION_KEY: undefined,
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
})); }));
// Re-import to get the new mock values // Re-import to get the new mock values
@@ -90,11 +88,10 @@ describe("generateSurveySingleUseId", () => {
expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set"); expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set");
}); });
it("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => { test("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => {
// Temporarily mock ENCRYPTION_KEY as undefined // Temporarily mock ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({ vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined, ENCRYPTION_KEY: undefined,
FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key",
})); }));
// Re-import to get the new mock values // Re-import to get the new mock values
@@ -102,19 +99,4 @@ describe("generateSurveySingleUseId", () => {
expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set"); expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set");
}); });
it("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => {
// Temporarily mock FORMBRICKS_ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key",
FORMBRICKS_ENCRYPTION_KEY: undefined,
}));
// Re-import to get the new mock values
const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys");
expect(() =>
validateSurveySingleUseIdNoKey("M(.Bob=dS1!wUSH2lb,E7hxO=He1cnnitmXrG|Su/DKYZrPy~zgS)u?dgI53sfs/")
).toThrow("FORMBRICKS_ENCRYPTION_KEY is not defined");
});
}); });
+9 -21
View File
@@ -1,6 +1,6 @@
import cuid2 from "@paralleldrive/cuid2"; import cuid2 from "@paralleldrive/cuid2";
import { ENCRYPTION_KEY, FORMBRICKS_ENCRYPTION_KEY } from "@formbricks/lib/constants"; import { ENCRYPTION_KEY } from "@formbricks/lib/constants";
import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto"; import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
// generate encrypted single use id for the survey // generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => { export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
@@ -21,25 +21,13 @@ export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => { export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
let decryptedCuid: string | null = null; let decryptedCuid: string | null = null;
if (surveySingleUseId.length === 64) { if (!ENCRYPTION_KEY) {
if (!FORMBRICKS_ENCRYPTION_KEY) { throw new Error("ENCRYPTION_KEY is not set");
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); }
} try {
decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY);
try { } catch (error) {
decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY, surveySingleUseId); return undefined;
} catch (error) {
return undefined;
}
} else {
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
try {
decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY);
} catch (error) {
return undefined;
}
} }
if (cuid2.isCuid(decryptedCuid)) { if (cuid2.isCuid(decryptedCuid)) {
+5 -5
View File
@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { SentryProvider } from "./SentryProvider"; import { SentryProvider } from "./SentryProvider";
vi.mock("@sentry/nextjs", async () => { vi.mock("@sentry/nextjs", async () => {
@@ -22,7 +22,7 @@ describe("SentryProvider", () => {
cleanup(); cleanup();
}); });
it("calls Sentry.init when sentryDsn is provided", () => { test("calls Sentry.init when sentryDsn is provided", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
@@ -47,7 +47,7 @@ describe("SentryProvider", () => {
); );
}); });
it("does not call Sentry.init when sentryDsn is not provided", () => { test("does not call Sentry.init when sentryDsn is not provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
render( render(
@@ -59,7 +59,7 @@ describe("SentryProvider", () => {
expect(initSpy).not.toHaveBeenCalled(); expect(initSpy).not.toHaveBeenCalled();
}); });
it("renders children", () => { test("renders children", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
render( render(
<SentryProvider sentryDsn={sentryDsn}> <SentryProvider sentryDsn={sentryDsn}>
@@ -69,7 +69,7 @@ describe("SentryProvider", () => {
expect(screen.getByTestId("child")).toHaveTextContent("Test Content"); expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
}); });
it("processes beforeSend correctly", () => { test("processes beforeSend correctly", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
+2
View File
@@ -47,6 +47,8 @@ export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) =>
}, },
}); });
} }
// We only want to run this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return <>{children}</>; return <>{children}</>;
+9 -9
View File
@@ -2,8 +2,8 @@ import { revalidateTag } from "next/cache";
interface RevalidateProps { interface RevalidateProps {
id?: string; id?: string;
environmentId?: string;
hashedKey?: string; hashedKey?: string;
organizationId?: string;
} }
export const apiKeyCache = { export const apiKeyCache = {
@@ -11,24 +11,24 @@ export const apiKeyCache = {
byId(id: string) { byId(id: string) {
return `apiKeys-${id}`; return `apiKeys-${id}`;
}, },
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-apiKeys`;
},
byHashedKey(hashedKey: string) { byHashedKey(hashedKey: string) {
return `apiKeys-${hashedKey}-apiKey`; return `apiKeys-${hashedKey}-apiKey`;
}, },
byOrganizationId(organizationId: string) {
return `organizations-${organizationId}-apiKeys`;
},
}, },
revalidate({ id, environmentId, hashedKey }: RevalidateProps): void { revalidate({ id, hashedKey, organizationId }: RevalidateProps): void {
if (id) { if (id) {
revalidateTag(this.tag.byId(id)); revalidateTag(this.tag.byId(id));
} }
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (hashedKey) { if (hashedKey) {
revalidateTag(this.tag.byHashedKey(hashedKey)); revalidateTag(this.tag.byHashedKey(hashedKey));
} }
if (organizationId) {
revalidateTag(this.tag.byOrganizationId(organizationId));
}
}, },
}; };
+1 -10
View File
@@ -155,7 +155,7 @@ export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => {
throw new ResourceNotFoundError("apiKey", apiKeyId); throw new ResourceNotFoundError("apiKey", apiKeyId);
} }
return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId); return apiKeyFromServer.organizationId;
}; };
export const getOrganizationIdFromInviteId = async (inviteId: string) => { export const getOrganizationIdFromInviteId = async (inviteId: string) => {
@@ -240,15 +240,6 @@ export const getProjectIdFromSegmentId = async (segmentId: string) => {
return await getProjectIdFromEnvironmentId(segment.environmentId); return await getProjectIdFromEnvironmentId(segment.environmentId);
}; };
export const getProjectIdFromApiKeyId = async (apiKeyId: string) => {
const apiKey = await getApiKey(apiKeyId);
if (!apiKey) {
throw new ResourceNotFoundError("apiKey", apiKeyId);
}
return await getProjectIdFromEnvironmentId(apiKey.environmentId);
};
export const getProjectIdFromActionClassId = async (actionClassId: string) => { export const getProjectIdFromActionClassId = async (actionClassId: string) => {
const actionClass = await getActionClass(actionClassId); const actionClass = await getActionClass(actionClassId);
if (!actionClass) { if (!actionClass) {
+2 -2
View File
@@ -51,7 +51,7 @@ export const getActionClass = reactCache(
); );
export const getApiKey = reactCache( export const getApiKey = reactCache(
async (apiKeyId: string): Promise<{ environmentId: string } | null> => async (apiKeyId: string): Promise<{ organizationId: string } | null> =>
cache( cache(
async () => { async () => {
validateInputs([apiKeyId, ZString]); validateInputs([apiKeyId, ZString]);
@@ -66,7 +66,7 @@ export const getApiKey = reactCache(
id: apiKeyId, id: apiKeyId,
}, },
select: { select: {
environmentId: true, organizationId: true,
}, },
}); });
+6 -2
View File
@@ -42,7 +42,7 @@ const enforceHttps = (request: NextRequest): Response | null => {
details: [ details: [
{ {
field: "", field: "",
issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.", issue: "Only HTTPS connections are allowed on the management endpoints.",
}, },
], ],
}; };
@@ -54,18 +54,22 @@ const enforceHttps = (request: NextRequest): Response | null => {
const handleAuth = async (request: NextRequest): Promise<Response | null> => { const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any }); const token = await getToken({ req: request as any });
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) { if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`; const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl"); const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) { if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) {
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 }); return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
} }
if (token && callbackUrl) { if (token && callbackUrl) {
return NextResponse.redirect(WEBAPP_URL + callbackUrl); return NextResponse.redirect(callbackUrl);
} }
return null; return null;
}; };
@@ -0,0 +1,74 @@
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
import { deleteUser } from "@formbricks/lib/user/service";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { deleteUserAction } from "./actions";
// Mock all dependencies
vi.mock("@formbricks/lib/user/service", () => ({
deleteUser: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
}));
// add a mock to authenticatedActionClient.action
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
action: (fn: any) => {
return fn;
},
},
}));
describe("deleteUserAction", () => {
test("deletes user successfully when multi-org is enabled", async () => {
const ctx = { user: { id: "test-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(true);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "test-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("test-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("test-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("deletes user successfully when multi-org is disabled but user is not sole owner of any org", async () => {
const ctx = { user: { id: "another-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "another-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "another-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("another-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("another-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("throws OperationNotAllowedError when user is sole owner in at least one org and multi-org is disabled", async () => {
const ctx = { user: { id: "sole-owner-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([
{ id: "org-1" } as TOrganization,
]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
await expect(() => deleteUserAction({ ctx } as any)).rejects.toThrow(OperationNotAllowedError);
expect(deleteUser).not.toHaveBeenCalled();
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("sole-owner-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,161 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import * as nextAuth from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import * as actions from "./actions";
import { DeleteAccountModal } from "./index";
vi.mock("next-auth/react", async () => {
const actual = await vi.importActual("next-auth/react");
return {
...actual,
signOut: vi.fn(),
};
});
vi.mock("./actions", () => ({
deleteUserAction: vi.fn(),
}));
describe("DeleteAccountModal", () => {
const mockUser: TUser = {
email: "test@example.com",
} as TUser;
const mockOrgs: TOrganization[] = [{ name: "Org1" }, { name: "Org2" }] as TOrganization[];
const mockSetOpen = vi.fn();
const mockLogout = vi.fn();
afterEach(() => {
cleanup();
});
test("renders modal with correct props", () => {
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={mockOrgs}
formbricksLogout={mockLogout}
/>
);
expect(screen.getByText("Org1")).toBeInTheDocument();
expect(screen.getByText("Org2")).toBeInTheDocument();
});
test("disables delete button when email does not match", () => {
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "wrong@example.com" } });
expect(input).toHaveValue("wrong@example.com");
});
test("allows account deletion flow (non-cloud)", async () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("allows account deletion flow (cloud)", async () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
Object.defineProperty(window, "location", {
writable: true,
value: { replace: vi.fn() },
});
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={true}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ redirect: true });
expect(window.location.replace).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("handles deletion errors", async () => {
const deleteUserAction = vi.spyOn(actions, "deleteUserAction").mockRejectedValue(new Error("fail"));
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
});
@@ -2,8 +2,7 @@
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react"; import { T, useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -88,6 +87,7 @@ export const DeleteAccountModal = ({
<li>{t("environments.settings.profile.warning_cannot_undo")}</li> <li>{t("environments.settings.profile.warning_cannot_undo")}</li>
</ul> </ul>
<form <form
data-testid="deleteAccountForm"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
await deleteAccount(); await deleteAccount();
@@ -98,6 +98,7 @@ export const DeleteAccountModal = ({
})} })}
</label> </label>
<Input <Input
data-testid="deleteAccountConfirmation"
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={user.email} placeholder={user.email}
@@ -0,0 +1,117 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { RatingSmiley } from "./index";
// Mock the smiley components from ../SingleResponseCard/components/Smileys
vi.mock("../SingleResponseCard/components/Smileys", () => ({
TiredFace: (props: any) => (
<span data-testid="TiredFace" className={props.className}>
TiredFace
</span>
),
WearyFace: (props: any) => (
<span data-testid="WearyFace" className={props.className}>
WearyFace
</span>
),
PerseveringFace: (props: any) => (
<span data-testid="PerseveringFace" className={props.className}>
PerseveringFace
</span>
),
FrowningFace: (props: any) => (
<span data-testid="FrowningFace" className={props.className}>
FrowningFace
</span>
),
ConfusedFace: (props: any) => (
<span data-testid="ConfusedFace" className={props.className}>
ConfusedFace
</span>
),
NeutralFace: (props: any) => (
<span data-testid="NeutralFace" className={props.className}>
NeutralFace
</span>
),
SlightlySmilingFace: (props: any) => (
<span data-testid="SlightlySmilingFace" className={props.className}>
SlightlySmilingFace
</span>
),
SmilingFaceWithSmilingEyes: (props: any) => (
<span data-testid="SmilingFaceWithSmilingEyes" className={props.className}>
SmilingFaceWithSmilingEyes
</span>
),
GrinningFaceWithSmilingEyes: (props: any) => (
<span data-testid="GrinningFaceWithSmilingEyes" className={props.className}>
GrinningFaceWithSmilingEyes
</span>
),
GrinningSquintingFace: (props: any) => (
<span data-testid="GrinningSquintingFace" className={props.className}>
GrinningSquintingFace
</span>
),
}));
describe("RatingSmiley", () => {
afterEach(() => {
cleanup();
});
const activeClass = "fill-rating-fill";
// Test branch: range === 10 => iconsIdx = [0,1,2,...,9]
test("renders correct icon for range 10 when active", () => {
// For idx 0, iconsIdx[0] === 0, which corresponds to TiredFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={10} addColors={true} />);
const icon = getByTestId("TiredFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
test("renders correct icon for range 10 when inactive", () => {
const { getByTestId } = render(<RatingSmiley active={false} idx={0} range={10} />);
const icon = getByTestId("TiredFace");
expect(icon).toBeDefined();
expect(icon.className).toContain("fill-none");
});
// Test branch: range === 7 => iconsIdx = [1,3,4,5,6,8,9]
test("renders correct icon for range 7 when active", () => {
// For idx 0, iconsIdx[0] === 1, which corresponds to WearyFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={7} addColors={true} />);
const icon = getByTestId("WearyFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 5 => iconsIdx = [3,4,5,6,7]
test("renders correct icon for range 5 when active", () => {
// For idx 0, iconsIdx[0] === 3, which corresponds to FrowningFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={5} addColors={true} />);
const icon = getByTestId("FrowningFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 4 => iconsIdx = [4,5,6,7]
test("renders correct icon for range 4 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={4} addColors={true} />);
const icon = getByTestId("ConfusedFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 3 => iconsIdx = [4,5,7]
test("renders correct icon for range 3 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={3} addColors={true} />);
const icon = getByTestId("ConfusedFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
});
@@ -40,16 +40,28 @@ const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean,
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none"; const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
const icons = [ const icons = [
<TiredFace className={active ? activeColor : inactiveColor} />, <TiredFace className={active ? activeColor : inactiveColor} data-testid="TiredFace" />,
<WearyFace className={active ? activeColor : inactiveColor} />, <WearyFace className={active ? activeColor : inactiveColor} data-testid="WearyFace" />,
<PerseveringFace className={active ? activeColor : inactiveColor} />, <PerseveringFace className={active ? activeColor : inactiveColor} data-testid="PerseveringFace" />,
<FrowningFace className={active ? activeColor : inactiveColor} />, <FrowningFace className={active ? activeColor : inactiveColor} data-testid="FrowningFace" />,
<ConfusedFace className={active ? activeColor : inactiveColor} />, <ConfusedFace className={active ? activeColor : inactiveColor} data-testid="ConfusedFace" />,
<NeutralFace className={active ? activeColor : inactiveColor} />, <NeutralFace className={active ? activeColor : inactiveColor} data-testid="NeutralFace" />,
<SlightlySmilingFace className={active ? activeColor : inactiveColor} />, <SlightlySmilingFace
<SmilingFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />, className={active ? activeColor : inactiveColor}
<GrinningFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />, data-testid="SlightlySmilingFace"
<GrinningSquintingFace className={active ? activeColor : inactiveColor} />, />,
<SmilingFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="SmilingFaceWithSmilingEyes"
/>,
<GrinningFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="GrinningFaceWithSmilingEyes"
/>,
<GrinningSquintingFace
className={active ? activeColor : inactiveColor}
data-testid="GrinningSquintingFace"
/>,
]; ];
return icons[iconIdx]; return icons[iconIdx];
@@ -0,0 +1,90 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getEnabledLanguages, getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LanguageDropdown } from "./LanguageDropdown";
vi.mock("@formbricks/lib/i18n/utils", () => ({
getEnabledLanguages: vi.fn(),
getLanguageLabel: vi.fn(),
}));
describe("LanguageDropdown", () => {
const dummySurveyMultiple = {
languages: [
{ language: { code: "en" } } as TSurveyLanguage,
{ language: { code: "fr" } } as TSurveyLanguage,
],
} as TSurvey;
const dummySurveySingle = {
languages: [{ language: { code: "en" } }],
} as TSurvey;
const dummyLocale = "en-US";
const setLanguageMock = vi.fn();
afterEach(() => {
cleanup();
});
test("renders nothing when enabledLanguages length is 1", () => {
vi.mocked(getEnabledLanguages).mockReturnValueOnce([{ language: { code: "en" } } as TSurveyLanguage]);
render(
<LanguageDropdown survey={dummySurveySingle} setLanguage={setLanguageMock} locale={dummyLocale} />
);
// Since enabledLanguages.length === 1, component should render null.
expect(screen.queryByRole("button")).toBeNull();
});
test("renders button and toggles dropdown when multiple languages exist", async () => {
vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages);
vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code.toUpperCase());
render(
<LanguageDropdown survey={dummySurveyMultiple} setLanguage={setLanguageMock} locale={dummyLocale} />
);
const button = screen.getByRole("button", { name: "Select Language" });
expect(button).toBeDefined();
await userEvent.click(button);
// Wait for the dropdown options to appear. They are wrapped in a div with no specific role,
// so we query for texts (our mock labels) instead.
const optionEn = await screen.findByText("EN");
const optionFr = await screen.findByText("FR");
expect(optionEn).toBeDefined();
expect(optionFr).toBeDefined();
await userEvent.click(optionFr);
expect(setLanguageMock).toHaveBeenCalledWith("fr");
// After clicking, dropdown should no longer be visible.
await waitFor(() => {
expect(screen.queryByText("EN")).toBeNull();
expect(screen.queryByText("FR")).toBeNull();
});
});
test("closes dropdown when clicking outside", async () => {
vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages);
vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code);
render(
<LanguageDropdown survey={dummySurveyMultiple} setLanguage={setLanguageMock} locale={dummyLocale} />
);
const button = screen.getByRole("button", { name: "Select Language" });
await userEvent.click(button);
// Confirm dropdown shown
expect(await screen.findByText("en")).toBeDefined();
// Simulate clicking outside by dispatching a click event on the container's parent.
await userEvent.click(document.body);
// Wait for dropdown to close
await waitFor(() => {
expect(screen.queryByText("en")).toBeNull();
});
});
});
@@ -1,8 +1,7 @@
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Languages } from "lucide-react"; import { Languages } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { getEnabledLanguages } from "@formbricks/lib/i18n/utils"; import { getEnabledLanguages, getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside"; import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
@@ -0,0 +1,22 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { SurveyLinkDisplay } from "./SurveyLinkDisplay";
describe("SurveyLinkDisplay", () => {
afterEach(() => {
cleanup();
});
test("renders the Input when surveyUrl is provided", () => {
const surveyUrl = "http://example.com/s/123";
render(<SurveyLinkDisplay surveyUrl={surveyUrl} />);
const input = screen.getByTestId("survey-url-input");
expect(input).toBeInTheDocument();
});
test("renders loading state when surveyUrl is empty", () => {
render(<SurveyLinkDisplay surveyUrl="" />);
const loadingDiv = screen.getByTestId("loading-div");
expect(loadingDiv).toBeInTheDocument();
});
});
@@ -9,13 +9,16 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<> <>
{surveyUrl ? ( {surveyUrl ? (
<Input <Input
data-testid="survey-url-input"
autoFocus={true} autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent" className="mt-2 w-full min-w-96 rounded-lg border bg-white px-4 py-2 text-ellipsis text-slate-800 caret-transparent"
value={surveyUrl} value={surveyUrl}
/> />
) : ( ) : (
//loading state //loading state
<div className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div> <div
data-testid="loading-div"
className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div>
)} )}
</> </>
); );
@@ -0,0 +1,247 @@
import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ShareSurveyLink } from "./index";
const dummySurvey = {
id: "survey123",
singleUse: { enabled: true, isEncrypted: false },
type: "link",
status: "completed",
} as any;
const dummySurveyDomain = "http://dummy.com";
const dummyLocale = "en-US";
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,
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("@/modules/survey/list/actions", () => ({
generateSingleUseIdAction: vi.fn(),
}));
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code",
() => ({
useSurveyQRCode: vi.fn(() => ({
downloadQRCode: vi.fn(),
})),
})
);
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((error: any) => error.message),
}));
vi.mock("./components/LanguageDropdown", () => {
const React = require("react");
return {
LanguageDropdown: (props: { setLanguage: (lang: string) => void }) => {
// Call setLanguage("fr-FR") when the component mounts to simulate a language change.
React.useEffect(() => {
props.setLanguage("fr-FR");
}, [props.setLanguage]);
return <div>Mocked LanguageDropdown</div>;
},
};
});
describe("ShareSurveyLink", () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
window.open = vi.fn();
});
afterEach(() => {
cleanup();
});
test("calls getUrl on mount and sets surveyUrl accordingly with singleUse enabled and default language", async () => {
// Inline mocks for this test
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
await waitFor(() => {
expect(setSurveyUrl).toHaveBeenCalled();
});
const url = setSurveyUrl.mock.calls[0][0];
expect(url).toContain(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
expect(url).not.toContain("lang=");
});
test("appends language query when language is changed from default", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
const DummyWrapper = () => (
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl="initial"
setSurveyUrl={setSurveyUrl}
locale="fr-FR"
/>
);
render(<DummyWrapper />);
await waitFor(() => {
const generatedUrl = setSurveyUrl.mock.calls[1][0];
expect(generatedUrl).toContain("lang=fr-FR");
});
});
test("preview button opens new window with preview query", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn().mockReturnValue(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const previewButton = await screen.findByRole("button", {
name: /environments.surveys.preview_survey_in_a_new_tab/i,
});
fireEvent.click(previewButton);
await waitFor(() => {
expect(window.open).toHaveBeenCalled();
const previewUrl = vi.mocked(window.open).mock.calls[0][0];
expect(previewUrl).toMatch(/\?suId=dummySuId(&|\\?)preview=true/);
});
});
test("copy button writes surveyUrl to clipboard and shows toast", async () => {
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
vi.mocked(copySurveyLink).mockImplementation((url: string, newId: string) => `${url}?suId=${newId}`);
const setSurveyUrl = vi.fn();
const surveyUrl = `${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`;
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const copyButton = await screen.findByRole("button", {
name: /environments.surveys.copy_survey_link_to_clipboard/i,
});
fireEvent.click(copyButton);
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
test("download QR code button calls downloadQRCode", async () => {
const dummyDownloadQRCode = vi.fn();
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
vi.mocked(useSurveyQRCode).mockReturnValue({ downloadQRCode: dummyDownloadQRCode } as any);
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const downloadButton = await screen.findByRole("button", {
name: /environments.surveys.summary.download_qr_code/i,
});
fireEvent.click(downloadButton);
expect(dummyDownloadQRCode).toHaveBeenCalled();
});
test("renders regenerate button when survey.singleUse.enabled is true and calls generateNewSingleUseLink", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const regenButton = await screen.findByRole("button", { name: /Regenerate single use survey link/i });
fireEvent.click(regenButton);
await waitFor(() => {
expect(generateSingleUseIdAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.surveys.new_single_use_link_generated");
});
});
test("handles error when generating single-use link fails", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: undefined });
vi.mocked(getFormattedErrorMessage).mockReturnValue("Failed to generate link");
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to generate link");
});
});
});
@@ -0,0 +1,206 @@
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromResponseId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromResponseId,
getOrganizationIdFromResponseNoteId,
getProjectIdFromEnvironmentId,
getProjectIdFromResponseId,
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { describe, expect, test, vi } from "vitest";
import { deleteResponse, getResponse } from "@formbricks/lib/response/service";
import {
createResponseNote,
resolveResponseNote,
updateResponseNote,
} from "@formbricks/lib/responseNote/service";
import { createTag } from "@formbricks/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
import {
createResponseNoteAction,
createTagAction,
createTagToResponseAction,
deleteResponseAction,
deleteTagOnResponseAction,
getResponseAction,
resolveResponseNoteAction,
updateResponseNoteAction,
} from "./actions";
// Dummy inputs and context
const dummyCtx = { user: { id: "user1" } };
const dummyTagInput = { environmentId: "env1", tagName: "tag1" };
const dummyTagToResponseInput = { responseId: "resp1", tagId: "tag1" };
const dummyResponseIdInput = { responseId: "resp1" };
const dummyResponseNoteInput = { responseNoteId: "note1", text: "Updated note" };
const dummyCreateNoteInput = { responseId: "resp1", text: "New note" };
const dummyGetResponseInput = { responseId: "resp1" };
// Mocks for external dependencies
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getProjectIdFromEnvironmentId: vi.fn().mockResolvedValue("proj-env"),
getOrganizationIdFromResponseId: vi.fn().mockResolvedValue("org-resp"),
getOrganizationIdFromResponseNoteId: vi.fn().mockResolvedValue("org-resp-note"),
getProjectIdFromResponseId: vi.fn().mockResolvedValue("proj-resp"),
getProjectIdFromResponseNoteId: vi.fn().mockResolvedValue("proj-resp-note"),
getEnvironmentIdFromResponseId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getTag: vi.fn(),
}));
vi.mock("@formbricks/lib/response/service", () => ({
deleteResponse: vi.fn().mockResolvedValue("deletedResponse"),
getResponse: vi.fn().mockResolvedValue({ data: "responseData" }),
}));
vi.mock("@formbricks/lib/responseNote/service", () => ({
createResponseNote: vi.fn().mockResolvedValue("createdNote"),
updateResponseNote: vi.fn().mockResolvedValue("updatedNote"),
resolveResponseNote: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/lib/tag/service", () => ({
createTag: vi.fn().mockResolvedValue("createdTag"),
}));
vi.mock("@formbricks/lib/tagOnResponse/service", () => ({
addTagToRespone: vi.fn().mockResolvedValue("tagAdded"),
deleteTagOnResponse: vi.fn().mockResolvedValue("tagDeleted"),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => async (input: any) => {
const { user, ...rest } = input;
return fn({
parsedInput: rest,
ctx: { user },
});
},
}),
},
}));
describe("createTagAction", () => {
test("successfully creates a tag", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org1");
await createTagAction({ ...dummyTagInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(getProjectIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(createTag).toHaveBeenCalledWith(dummyTagInput.environmentId, dummyTagInput.tagName);
});
});
describe("createTagToResponseAction", () => {
test("adds tag to response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getEnvironmentIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(addTagToRespone).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteTagOnResponseAction", () => {
test("deletes tag on response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(deleteTagOnResponse).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteResponseAction", () => {
test("deletes response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await deleteResponseAction({ ...dummyResponseIdInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(deleteResponse).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
});
});
describe("updateResponseNoteAction", () => {
test("updates response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await updateResponseNoteAction({ ...dummyResponseNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(updateResponseNote).toHaveBeenCalledWith(
dummyResponseNoteInput.responseNoteId,
dummyResponseNoteInput.text
);
});
});
describe("resolveResponseNoteAction", () => {
test("resolves response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await resolveResponseNoteAction({ responseNoteId: "note1", ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(resolveResponseNote).toHaveBeenCalledWith("note1");
});
});
describe("createResponseNoteAction", () => {
test("creates a response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await createResponseNoteAction({ ...dummyCreateNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(createResponseNote).toHaveBeenCalledWith(
dummyCreateNoteInput.responseId,
dummyCtx.user.id,
dummyCreateNoteInput.text
);
});
});
describe("getResponseAction", () => {
test("retrieves response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await getResponseAction({ ...dummyGetResponseInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getResponse).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
});
});
@@ -0,0 +1,70 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyHiddenFields } from "@formbricks/types/surveys/types";
import { HiddenFields } from "./HiddenFields";
// Mock tooltip components to always render their children
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe("HiddenFields", () => {
afterEach(() => {
cleanup();
});
test("renders empty container when no fieldIds are provided", () => {
render(
<HiddenFields hiddenFields={{ fieldIds: [] } as unknown as TSurveyHiddenFields} responseData={{}} />
);
const container = screen.getByTestId("main-hidden-fields-div");
expect(container).toBeDefined();
});
test("renders nothing for fieldIds with no corresponding response data", () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{}}
/>
);
expect(screen.queryByText("field1")).toBeNull();
});
test("renders field and value when responseData exists and is a string", async () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1", "field2"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: "Value 1", field2: "" }}
/>
);
expect(screen.getByText("field1")).toBeInTheDocument();
expect(screen.getByText("Value 1")).toBeInTheDocument();
expect(screen.queryByText("field2")).toBeNull();
});
test("renders empty text when responseData value is not a string", () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: { any: "object" } }}
/>
);
expect(screen.getByText("field1")).toBeInTheDocument();
const valueParagraphs = screen.getAllByText("", { selector: "p" });
expect(valueParagraphs.length).toBeGreaterThan(0);
});
test("displays tooltip content for hidden field", async () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: "Value 1" }}
/>
);
expect(screen.getByText("common.hidden_field")).toBeInTheDocument();
});
});
@@ -15,7 +15,7 @@ export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps)
const { t } = useTranslate(); const { t } = useTranslate();
const fieldIds = hiddenFields.fieldIds ?? []; const fieldIds = hiddenFields.fieldIds ?? [];
return ( return (
<div className="mt-6 flex flex-col gap-6"> <div data-testid="main-hidden-fields-div" className="mt-6 flex flex-col gap-6">
{fieldIds.map((field) => { {fieldIds.map((field) => {
if (!responseData[field]) return; if (!responseData[field]) return;
return ( return (
@@ -0,0 +1,98 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { QuestionSkip } from "./QuestionSkip";
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
TooltipProvider: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@/modules/i18n/utils", () => ({
getLocalizedValue: vi.fn((value, _) => value),
}));
// Mock recall utils
vi.mock("@formbricks/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((headline, _) => {
return `parsed: ${headline}`;
}),
}));
const dummyQuestions = [
{ id: "f1", headline: "headline1" },
{ id: "f2", headline: "headline2" },
] as unknown as TSurveyQuestion[];
const dummyResponseData = { f1: "Answer 1", f2: "Answer 2" };
describe("QuestionSkip", () => {
afterEach(() => {
cleanup();
});
test("renders nothing when skippedQuestions is falsy", () => {
render(
<QuestionSkip
skippedQuestions={undefined}
status="skipped"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
expect(screen.queryByText("headline1")).toBeNull();
expect(screen.queryByText("headline2")).toBeNull();
});
test("renders welcomeCard branch", () => {
render(
<QuestionSkip
skippedQuestions={["f1"]}
status="welcomeCard"
questions={dummyQuestions}
responseData={{ f1: "Answer 1" }}
isFirstQuestionAnswered={false}
/>
);
expect(screen.getByText("common.welcome_card")).toBeInTheDocument();
});
test("renders skipped branch with tooltip and parsed headlines", () => {
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1");
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2");
render(
<QuestionSkip
skippedQuestions={["f1", "f2"]}
status="skipped"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
// Check tooltip text from TooltipContent
expect(screen.getByTestId("tooltip-respondent_skipped_questions")).toBeInTheDocument();
// Check mapping: parseRecallInfo should be called on each headline value, so expect the parsed text to appear.
expect(screen.getByText("parsed: headline1")).toBeInTheDocument();
expect(screen.getByText("parsed: headline2")).toBeInTheDocument();
});
test("renders aborted branch with closed message and parsed headlines", () => {
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1");
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2");
render(
<QuestionSkip
skippedQuestions={["f1", "f2"]}
status="aborted"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
expect(screen.getByTestId("tooltip-survey_closed")).toBeInTheDocument();
expect(screen.getByText("parsed: headline1")).toBeInTheDocument();
expect(screen.getByText("parsed: headline2")).toBeInTheDocument();
});
});

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